diff --git a/docs/architecture.md b/docs/architecture.md index 2c5611f..b4572fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -329,7 +329,11 @@ Buildings and the belt subsystem stay outside any entity model regardless of wha ## Rendering -The game world is rendered by a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization. +The game world is drawn into a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization. + +The drawing itself lives in `WorldRenderer`, not in the widget. `paintGL` is a call sequence: build the frame's `WorldCoordinates`, hand the renderer a `WorldRenderFrame`, then draw the screen-anchored chrome. The split is the world-space / screen-space line: the renderer draws everything positioned in tiles, while the pause and deconstruct vignettes and the replay overlay — which are anchored to the viewport and never took a `WorldCoordinates` — stay with the widget. + +`WorldRenderFrame` is what makes the renderer independent of the widget. The renderer reads the simulation directly, but everything else it draws is interaction state the widget owns — the selection, the active build mode, live beams, the copy-settings feedback, the box-select rectangle. Those are gathered into the frame each `paintGL` and passed by reference, so the renderer keeps no copy that a later click could invalidate. The renderer knows nothing about input: the widget resolves clicks and hit-tests, and the renderer only draws the result. ### Render Loop diff --git a/src/lib/core/TunnelCompletion.cpp b/src/lib/core/TunnelCompletion.cpp index d8c2f4d..f523909 100644 --- a/src/lib/core/TunnelCompletion.cpp +++ b/src/lib/core/TunnelCompletion.cpp @@ -153,3 +153,13 @@ std::optional findTunnelPartner(const TunnelLookup& lookup, QPoint tile, return std::nullopt; } + +TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels) +{ + return [&tunnels](QPoint tile) -> std::optional + { + const TunnelTileMap::const_iterator it = tunnels.find(tile); + if (it == tunnels.end()) { return std::nullopt; } + return it->second; + }; +} diff --git a/src/lib/core/TunnelCompletion.h b/src/lib/core/TunnelCompletion.h index 4c85c82..941de46 100644 --- a/src/lib/core/TunnelCompletion.h +++ b/src/lib/core/TunnelCompletion.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -9,6 +10,17 @@ #include "BuildingType.h" #include "Rotation.h" +// QPoint has no operator<, so an explicit ordering is needed to key a map or set +// by tile. +struct QPointCompare +{ + bool operator()(const QPoint& a, const QPoint& b) const + { + if (a.x() != b.x()) { return a.x() < b.x(); } + return a.y() < b.y(); + } +}; + // A tunnel building occupying a single tile: whether it is an entry or an exit and // the direction it faces. Used by the tunnel pairing scan (REQ-BLD-TUNNEL-PAIR) and // the unified tunnel build mode (REQ-BLD-TUNNEL-MODE). @@ -22,6 +34,13 @@ struct TunnelTileInfo // direction, or std::nullopt when the tile holds no tunnel building. using TunnelLookup = std::function(QPoint)>; +// Tunnel entries/exits indexed by their single-cell tile (REQ-BLD-TUNNEL-MODE). +using TunnelTileMap = std::map; + +// Wraps a tunnel tile index in the lookup functor the helpers below take. The +// returned functor references `tunnels`, which must outlive it. +TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels); + // Steps from `start` in `stepDir` over the tiles at distance 1..maxDistance and // returns the first tile whose tunnel faces `targetFacing`. Tunnel buildings facing // any other direction are skipped, mirroring the "stop at the first same-direction diff --git a/src/lib/sim/FactoryQueries.cpp b/src/lib/sim/FactoryQueries.cpp index 877af01..63834c9 100644 --- a/src/lib/sim/FactoryQueries.cpp +++ b/src/lib/sim/FactoryQueries.cpp @@ -1,5 +1,6 @@ #include "FactoryQueries.h" +#include #include #include "PortGeometry.h" @@ -179,3 +180,61 @@ getSiteSplitterInfo(const FactoryState& state, const GameConfig& config, Buildin return std::nullopt; } + +std::vector buildingsInBox(const FactoryState& state, + QPoint cornerA, QPoint cornerB) +{ + const int x0 = std::min(cornerA.x(), cornerB.x()); + const int y0 = std::min(cornerA.y(), cornerB.y()); + const int x1 = std::max(cornerA.x(), cornerB.x()); + const int y1 = std::max(cornerA.y(), cornerB.y()); + + const auto covers = [&](const std::vector& bodyCells) + { + for (const QPoint& cell : bodyCells) + { + if (cell.x() >= x0 && cell.x() <= x1 + && cell.y() >= y0 && cell.y() <= y1) + { + return true; + } + } + return false; + }; + + std::vector ids; + for (const Building& building : getAllBuildings(state)) + { + if (covers(building.bodyCells)) { ids.push_back(building.id); } + } + for (const ConstructionSite& site : getAllSites(state)) + { + if (covers(site.bodyCells)) { ids.push_back(site.id); } + } + return ids; +} + +TunnelTileMap collectTunnelTiles(const FactoryState& state) +{ + // Index every tunnel entry/exit — built or still a construction site — by its + // single-cell tile, so a just-placed tunnel (not yet constructed) is matchable + // (REQ-BLD-TUNNEL-MODE, REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). + TunnelTileMap tunnels; + for (const Building& building : getAllBuildings(state)) + { + if (building.type == BuildingType::TunnelEntry + || building.type == BuildingType::TunnelExit) + { + tunnels[building.anchor] = TunnelTileInfo{building.type, building.rotation}; + } + } + for (const ConstructionSite& site : getAllSites(state)) + { + if (site.type == BuildingType::TunnelEntry + || site.type == BuildingType::TunnelExit) + { + tunnels[site.anchor] = TunnelTileInfo{site.type, site.rotation}; + } + } + return tunnels; +} diff --git a/src/lib/sim/FactoryQueries.h b/src/lib/sim/FactoryQueries.h index 5c65fec..77c11ef 100644 --- a/src/lib/sim/FactoryQueries.h +++ b/src/lib/sim/FactoryQueries.h @@ -12,6 +12,7 @@ #include "FactoryState.h" #include "GameConfig.h" #include "Port.h" +#include "TunnelCompletion.h" // Queries and operations over the factory's world data that need nothing but that // data — no config, no belts, no RNG. Free functions rather than BuildingSystem @@ -69,3 +70,13 @@ std::vector getInputPorts(const FactoryState& state, const GameConfig& con std::optional getSiteSplitterInfo(const FactoryState& state, const GameConfig& config, BuildingId id); + +// Ids of all buildings and construction sites whose footprint intersects the tile +// box spanned by the two (unordered) corner tiles (REQ-UI-MULTI-SELECT, +// REQ-BLD-DECONSTRUCT-BOX). +std::vector buildingsInBox(const FactoryState& state, + QPoint cornerA, QPoint cornerB); + +// Every tunnel entry and exit, built or still a construction site, indexed by its +// single-cell tile. Shared by the placement preview and the selection highlight. +TunnelTileMap collectTunnelTiles(const FactoryState& state); diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 08bb948..a3f96f4 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -8,6 +8,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h ${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h + ${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h @@ -32,6 +33,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp ${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index c74f68d..d32d282 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -1,7 +1,6 @@ #include "GameWorldView.h" #include "PlacementRules.h" #include "FactoryQueries.h" -#include "ProductionRules.h" #include #include @@ -12,7 +11,6 @@ #include #include -#include #include #include #include @@ -28,16 +26,10 @@ #include #include #include -#include #include -#include -#include #include -#include #include -#include "AttackBehavior.h" -#include "BeltSystem.h" #include "Building.h" #include "BuildingSystem.h" #include "Command.h" @@ -47,26 +39,19 @@ #include "DeconstructModeChangedEvent.h" #include "EntityHitTest.h" #include "EventManager.h" -#include "FacingComponent.h" #include "FactionComponent.h" #include "GameOverEvent.h" #include "HealthComponent.h" -#include "HqProxyComponent.h" #include "ItemIconCache.h" -#include "PortGeometry.h" #include "PositionComponent.h" -#include "RepairBehavior.h" -#include "SalvageScrapBehavior.h" #include "DebrisSystem.h" #include "SelectionChangedEvent.h" -#include "SensorRangeComponent.h" #include "ShipIdentityComponent.h" #include "ShipSystem.h" #include "Simulation.h" #include "StationBodyComponent.h" #include "DebrisComponent.h" #include "SurfaceMask.h" -#include "WorldPrimitives.h" #include "Tick.h" #include "TunnelCompletion.h" #include "EscapeMenuRequestedEvent.h" @@ -87,52 +72,6 @@ namespace { -// --- World building icons (REQ-UI-WORLD-ICON) -------------------------------- - -// Building types that render with a world icon, and the icon file name for each. -// Belts, splitters, and tunnels are intentionally absent so their orientation -// stays readable; the two defence stations share one symbol (colored per side by -// the fill it is drawn over). -struct WorldIconEntry { BuildingType type; const char* file; }; -const WorldIconEntry kWorldIconFiles[] = { - { BuildingType::Miner, "miner.svg" }, - { BuildingType::Smelter, "smelter.svg" }, - { BuildingType::Assembler, "assembler.svg" }, - { BuildingType::ReprocessingPlant, "reprocessing_plant.svg" }, - { BuildingType::Shipyard, "shipyard.svg" }, - { BuildingType::SalvageBay, "salvage_bay.svg" }, - { BuildingType::Hq, "hq.svg" }, - { BuildingType::PlayerDefenceStation, "station.svg" }, - { BuildingType::EnemyDefenceStation, "station.svg" }, -}; - -// On-screen size of every world icon, as a multiple of one tile (REQ-UI-WORLD-ICON): -// a little over one tile so all buildings' icons read at the same size regardless -// of footprint. -const qreal kWorldIconTileFactor = 1.25; - -// Produces an icon SVG containing only the glyph (the chip background rect -// stripped), with the white glyph stroke recolored to inkHex. -QByteArray worldIconSvg(const QByteArray& svg, const QString& inkHex) -{ - QString s = QString::fromUtf8(svg); - // Drop the full-canvas chip rect only; some glyphs use their own - // elements (e.g. the miner's drill housing, the hq's base), so match the - // 100x100 background rect specifically. - static const QRegularExpression backgroundRect( - QStringLiteral("]*>")); - s.remove(backgroundRect); - s.replace(QStringLiteral("#ffffff"), inkHex); - return s.toUtf8(); -} - -// Perceived luminance test; picks a dark glyph on light fills so it stays legible. -bool isLightFill(const QColor& c) -{ - const double lum = (0.299 * c.red() + 0.587 * c.green() + 0.114 * c.blue()) / 255.0; - return lum > 0.6; -} - // Keep only the filter entries whose item type is currently unlocked // (REQ-LOCK-UI-BLUEPRINT). An empty result means "accept all". std::vector filterUnlockedItems(const std::vector& filter, @@ -170,20 +109,6 @@ Rotation rotateCounterClockwise(Rotation r) return Rotation::East; } -// Fill color for a building's status light per its production state -// (REQ-UI-STATUS-LIGHT). -QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl) -{ - switch (status) - { - case ProductionStatus::Unconfigured: return sl.grey; - case ProductionStatus::Producing: return sl.green; - case ProductionStatus::Starved: return sl.red; - case ProductionStatus::Blocked: return sl.yellow; - } - return sl.grey; -} - } // namespace @@ -195,7 +120,6 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, , m_sim(sim) , m_config(config) , m_visuals(visuals) - , m_itemIcons(itemIcons) , m_commandManager(*sim) , m_gameSpeedMultiplier(1.0) , m_prevNonZeroSpeed(1.0) @@ -209,7 +133,7 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, setFocusPolicy(Qt::StrongFocus); setMouseTracking(true); - loadBuildingIcons(configDir); + m_renderer = std::make_unique(*sim, *visuals, itemIcons, configDir); m_renderTimer = new QTimer(this); m_renderTimer->setInterval(16); @@ -477,36 +401,22 @@ void GameWorldView::paintGL() QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, false); - // One transform snapshot for the whole frame; every world-space draw below - // reads the viewport through it. - const WorldCoordinates coordinates = getCoordinates(); + m_renderer->render(painter, getCoordinates(), makeRenderFrame()); - drawTiles(painter, coordinates); - drawBuildings(painter, coordinates); - // Port items are drawn over the buildings but clipped to a thin margin at each - // machine's edges (see drawPortItems), so items appear to emerge from / sink - // into the port and stay visible while crossing directly between two touching - // buildings (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE, REQ-MAT-DIRECT-COUPLE). - drawPortItems(painter, coordinates); - drawCopyConfigFeedback(painter, coordinates); - drawStations(painter, coordinates); - drawBeltItems(painter, coordinates); - drawDebris(painter, coordinates); - if (m_debugDraw) - { - drawDebugSensorRanges(painter, coordinates); - drawDebugTargetLines(painter, coordinates); - drawDebugOverlay(painter); - } - drawShips(painter, coordinates); - drawBeams(painter, coordinates); - drawOverlays(painter, coordinates); + // Screen-anchored chrome over the finished world. drawScreenSpace(painter); drawPauseBorder(painter); drawDeconstructBorder(painter); drawReplayOverlay(painter); } +WorldRenderFrame GameWorldView::makeRenderFrame() const +{ + return WorldRenderFrame{m_selection, m_buildMode, m_activeBeams, m_copiedConfig, + m_copyConfigFlashes, m_boxSelecting, m_boxStartTile, + m_boxCurrentTile, m_debugDraw}; +} + // --------------------------------------------------------------------------- // Coordinate helpers // --------------------------------------------------------------------------- @@ -600,51 +510,6 @@ std::optional GameWorldView::siteAtTile(QPoint tile) const return std::nullopt; } - -std::vector GameWorldView::buildingsInBox(QPoint cornerA, QPoint cornerB) const -{ - const int x0 = std::min(cornerA.x(), cornerB.x()); - const int y0 = std::min(cornerA.y(), cornerB.y()); - const int x1 = std::max(cornerA.x(), cornerB.x()); - const int y1 = std::max(cornerA.y(), cornerB.y()); - - std::vector ids; - for (const Building& b : getAllBuildings(m_sim->getFactoryState())) - { - for (const QPoint& cell : b.bodyCells) - { - if (cell.x() >= x0 && cell.x() <= x1 - && cell.y() >= y0 && cell.y() <= y1) - { - ids.push_back(b.id); - break; - } - } - } - for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState())) - { - for (const QPoint& cell : s.bodyCells) - { - if (cell.x() >= x0 && cell.x() <= x1 - && cell.y() >= y0 && cell.y() <= y1) - { - ids.push_back(s.id); - break; - } - } - } - return ids; -} - -std::optional GameWorldView::entityPosition(entt::entity entity) const -{ - if (!m_sim->getAdmin().isValid(entity) || !m_sim->getAdmin().hasAll(entity)) - { - return std::nullopt; - } - return m_sim->getAdmin().get(entity).value; -} - void GameWorldView::pruneDespawnedDebris() { const std::vector& selected = m_selection.getSelectedDebris(); @@ -791,39 +656,6 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) } } -TunnelTileMap GameWorldView::collectTunnelTiles() const -{ - // Index every tunnel entry/exit — built or still a construction site — by its - // single-cell tile, so a just-placed tunnel (not yet constructed) is matchable - // (REQ-BLD-TUNNEL-MODE, REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). - TunnelTileMap tunnels; - for (const Building& b : getAllBuildings(m_sim->getFactoryState())) - { - if (b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit) - { - tunnels[b.anchor] = TunnelTileInfo{b.type, b.rotation}; - } - } - for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState())) - { - if (s.type == BuildingType::TunnelEntry || s.type == BuildingType::TunnelExit) - { - tunnels[s.anchor] = TunnelTileInfo{s.type, s.rotation}; - } - } - return tunnels; -} - -TunnelLookup GameWorldView::makeTunnelLookup(const TunnelTileMap& tunnels) -{ - return [&tunnels](QPoint tile) -> std::optional - { - const TunnelTileMap::const_iterator it = tunnels.find(tile); - if (it == tunnels.end()) { return std::nullopt; } - return it->second; - }; -} - void GameWorldView::updateTunnelGhost() { // The connection preview and entry/exit switch only apply at a valid placement @@ -834,7 +666,7 @@ void GameWorldView::updateTunnelGhost() return; } - const TunnelTileMap tunnels = collectTunnelTiles(); + const TunnelTileMap tunnels = collectTunnelTiles(m_sim->getFactoryState()); const TunnelLookup lookup = makeTunnelLookup(tunnels); const TunnelCompletion completion = @@ -988,1059 +820,6 @@ void GameWorldView::applyBeltDragPath() // Port glyph helper // --------------------------------------------------------------------------- -void GameWorldView::drawPortGlyph(QPainter& painter, - const WorldCoordinates& coordinates, QPoint tile, - Rotation direction, const QColor& color, - bool centered) -{ - const float px = coordinates.getTilePx(); - const QRectF tr = coordinates.tileRect(tile); - const QPointF center(tr.x() + static_cast(px) * 0.5, - tr.y() + static_cast(px) * 0.5); - - QPointF edgeOffset; - const char* ch; - switch (direction) - { - case Rotation::East: edgeOffset = QPointF(px * 0.25f, 0); ch = ">"; break; - case Rotation::West: edgeOffset = QPointF(-px * 0.25f, 0); ch = "<"; break; - case Rotation::North: edgeOffset = QPointF(0, -px * 0.25f); ch = "^"; break; - case Rotation::South: edgeOffset = QPointF(0, px * 0.25f); ch = "v"; break; - default: return; - } - - // Centered glyphs sit in the middle of the tile (used for a port's target - // cell, REQ-UI-PORT-TARGET-GLYPH) and are drawn 150% larger to stand out; - // otherwise offset toward the exit edge of the port's body tile - // (REQ-UI-PORT-GLYPH). - const QPointF offset = centered ? QPointF(0.0, 0.0) : edgeOffset; - const qreal glyphScale = centered ? 1.5 : 1.0; - - const qreal half = static_cast(px) * 0.3 * glyphScale; - const QPointF pos = center + offset; - const QRectF textRect(pos.x() - half, pos.y() - half, half * 2.0, half * 2.0); - - QFont f = painter.font(); - f.setPixelSize(std::max(6, static_cast(px * 0.4f * glyphScale))); - painter.setFont(f); - painter.setPen(color); - painter.drawText(textRect, Qt::AlignCenter, QString::fromLatin1(ch)); -} - -// --------------------------------------------------------------------------- -// Rendering -// --------------------------------------------------------------------------- - -void GameWorldView::drawTiles(QPainter& painter, const WorldCoordinates& coordinates) -{ - const int leftTile = static_cast(std::floor(coordinates.getViewLeftTiles())) - 1; - const int rightTile = leftTile - + static_cast(std::ceil(coordinates.getViewportWidthTiles())) + 2; - const int bottomTile = m_config->world.heightTiles; - - // Asteroid columns left of the buildable edge are not yet unlocked by - // expansion; tint them so the player sees the reachable-but-locked area. - const int buildableLeftX = -m_sim->getCurrentAsteroidWidth_tiles(); - - painter.setPen(Qt::NoPen); - for (int x = leftTile; x <= rightTile; ++x) - { - const QColor& fill = (x < 0) - ? m_visuals->asteroid.fill - : m_visuals->space.fill; - const bool locked = (x < buildableLeftX); - for (int y = 0; y < bottomTile; ++y) - { - const QRectF rect = coordinates.tileRect(QPoint(x, y)); - painter.fillRect(rect, fill); - if (locked) - { - painter.fillRect(rect, m_visuals->overlays.lockedAsteroid); - } - } - } -} - -void GameWorldView::loadBuildingIcons(const std::string& configDir) -{ - // Icons live beside the config dir, read at runtime like visuals.toml - // (REQ-UI-WORLD-ICON), mirroring how MainWindow derives the same path. - const QString iconDir = QDir::cleanPath( - QString::fromStdString(configDir) + "/../icons/buildings"); - - for (const WorldIconEntry& entry : kWorldIconFiles) - { - QFile file(iconDir + "/" + QString::fromLatin1(entry.file)); - if (!file.open(QIODevice::ReadOnly)) - { - continue; // A missing icon is not an error; the text glyph is used. - } - const QByteArray svg = file.readAll(); - BuildingIconRenderers renderers; - renderers.white = std::make_unique( - worldIconSvg(svg, QStringLiteral("#ffffff"))); - renderers.dark = std::make_unique( - worldIconSvg(svg, QStringLiteral("#1c1c20"))); - m_buildingIcons[entry.type] = std::move(renderers); - } -} - -bool GameWorldView::drawBuildingIcon(QPainter& painter, - const WorldCoordinates& coordinates, - BuildingType type, - const QRectF& box, const QColor& fill) const -{ - const std::map::const_iterator it = - m_buildingIcons.find(type); - if (it == m_buildingIcons.end()) { return false; } - - // Every icon is the same fixed on-screen size, centered on the footprint, - // regardless of footprint size (REQ-UI-WORLD-ICON). - const qreal side = static_cast(coordinates.getTilePx()) * kWorldIconTileFactor; - const QRectF target(box.center().x() - side / 2.0, - box.center().y() - side / 2.0, side, side); - QSvgRenderer* renderer = isLightFill(fill) ? it->second.dark.get() - : it->second.white.get(); - - // Render the glyph as vector at the view scale so it stays crisp (a - // pre-rasterized pixmap downscaled to tile size looked blurry). - const bool wasAntialiasing = painter.testRenderHint(QPainter::Antialiasing); - painter.setRenderHint(QPainter::Antialiasing, true); - renderer->render(&painter, target); - painter.setRenderHint(QPainter::Antialiasing, wasAntialiasing); - return true; -} - -void GameWorldView::drawBuildings(QPainter& painter, const WorldCoordinates& coordinates) -{ - for (const Building& b : getAllBuildings(m_sim->getFactoryState())) - { - const std::map::const_iterator it = - m_visuals->buildings.find(b.type); - if (it == m_visuals->buildings.end()) { continue; } - const BuildingVisuals& bv = it->second; - - painter.setPen(Qt::NoPen); - for (const QPoint& cell : b.bodyCells) - { - painter.fillRect(coordinates.tileRect(cell), bv.fill); - } - - const QPointF tl = coordinates.tileToWidget(b.anchor); - const QRectF bboxRect(tl.x(), tl.y(), - b.footprint.width() * static_cast(coordinates.getTilePx()), - b.footprint.height() * static_cast(coordinates.getTilePx())); - - painter.setPen(QPen(bv.outline, 1)); - painter.setBrush(Qt::NoBrush); - painter.drawRect(bboxRect); - - // Icon glyph over the fill (REQ-UI-WORLD-ICON); falls back to the text - // glyph for building types without a world icon (e.g. tunnels). - if (!drawBuildingIcon(painter, coordinates, b.type, bboxRect, bv.fill) - && !bv.glyph.isEmpty()) - { - painter.setPen(bv.outline); - painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph); - } - - for (const Port& port : b.outputPorts) - { - drawPortGlyph(painter, coordinates, - outputBodyTile(port.tile, port.direction), - port.direction, bv.outline, /*centered*/ false); - } - - // Status light: a small circle in the building's upper-right corner - // (in default/East orientation), anchored to that footprint corner and - // rotating with the building (REQ-UI-STATUS-LIGHT). All status-light - // building footprints are rectangular, so a corner of the axis-aligned - // bounding box is the true corner. - if (const std::optional status = - getProductionStatus(m_sim->getConfig(), b)) - { - const float px = coordinates.getTilePx(); - const float r = px * 0.18f; - const float inset = r + px * 0.12f; - // Default orientation (East) puts the light at the top-right corner; - // clockwise rotation carries it around the footprint. - QPointF center(bboxRect.right() - inset, bboxRect.top() + inset); - switch (b.rotation) - { - case Rotation::East: break; - case Rotation::South: center = QPointF(bboxRect.right() - inset, - bboxRect.bottom() - inset); break; - case Rotation::West: center = QPointF(bboxRect.left() + inset, - bboxRect.bottom() - inset); break; - case Rotation::North: center = QPointF(bboxRect.left() + inset, - bboxRect.top() + inset); break; - } - painter.setBrush(statusLightFill(*status, m_visuals->statusLight)); - painter.setPen(QPen(m_visuals->statusLight.outline, 1)); - painter.drawEllipse(center, r, r); - } - } - - painter.setOpacity(0.5); - for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState())) - { - const std::map::const_iterator it = - m_visuals->buildings.find(s.type); - if (it == m_visuals->buildings.end()) { continue; } - const BuildingVisuals& bv = it->second; - - for (const QPoint& cell : s.bodyCells) - { - painter.fillRect(coordinates.tileRect(cell), bv.fill); - } - - const QPointF tl = coordinates.tileToWidget(s.anchor); - const QRectF bboxRect(tl.x(), tl.y(), - s.footprint.width() * static_cast(coordinates.getTilePx()), - s.footprint.height() * static_cast(coordinates.getTilePx())); - painter.setPen(QPen(bv.outline, 1, Qt::DashLine)); - painter.setBrush(Qt::NoBrush); - painter.drawRect(bboxRect); - - const BuildingDef* siteDef = m_config->buildings.findBuildingDef(s.type); - if (siteDef) - { - // Glyph + progress percentage - const Tick durationTicks = secondsToTicks(siteDef->constructionTimeSeconds); - int pct = 0; - if (s.completesAt > 0 && durationTicks > 0) - { - const Tick elapsed = m_sim->getCurrentTick() - - (s.completesAt - durationTicks); - pct = static_cast( - std::max(Tick(0), std::min(durationTicks, elapsed)) - * 100 / durationTicks); - } - const QString pctText = QString::number(pct) + "%"; - - painter.setPen(bv.outline); - // Identity symbol with the progress percentage below it - // (REQ-UI-CONSTRUCTION-PROGRESS): the world icon centered on the - // footprint where the type has one, otherwise the text glyph. - if (drawBuildingIcon(painter, coordinates, s.type, bboxRect, bv.fill)) - { - painter.drawText(bboxRect, Qt::AlignHCenter | Qt::AlignBottom, pctText); - } - else if (!bv.glyph.isEmpty()) - { - const QRectF topHalf(bboxRect.x(), bboxRect.y(), - bboxRect.width(), bboxRect.height() * 0.5); - const QRectF botHalf(bboxRect.x(), - bboxRect.y() + bboxRect.height() * 0.5, - bboxRect.width(), bboxRect.height() * 0.5); - painter.drawText(topHalf, Qt::AlignCenter, bv.glyph); - painter.drawText(botHalf, Qt::AlignCenter, pctText); - } - else - { - painter.drawText(bboxRect, Qt::AlignCenter, pctText); - } - - // Port glyphs - const ParsedSurfaceMask siteMask = - parseSurfaceMask(siteDef->surfaceMask, s.rotation); - for (const Port& port : siteMask.outputPorts) - { - const QPoint absBody = s.anchor - + outputBodyTile(port.tile, port.direction); - drawPortGlyph(painter, coordinates, absBody, port.direction, - bv.outline, /*centered*/ false); - } - } - } - painter.setOpacity(1.0); - - // HP bar below the HQ footprint; the HQ's HP lives on its proxy entity. Drawn - // after every building and construction site fill so a belt (or other tile) - // placed directly below the HQ cannot overpaint the bar (REQ-UI-STATUS-LIGHT - // neighbours case, same rationale as the selection highlights below). - for (const Building& b : getAllBuildings(m_sim->getFactoryState())) - { - if (b.type != BuildingType::Hq) { continue; } - const QPointF tl = coordinates.tileToWidget(b.anchor); - const QRectF bboxRect(tl.x(), tl.y(), - b.footprint.width() * static_cast(coordinates.getTilePx()), - b.footprint.height() * static_cast(coordinates.getTilePx())); - m_sim->getAdmin().forEach( - [&](entt::entity /*e*/, const HqProxyComponent& /*hq*/, - const FactionComponent& f, const HealthComponent& h) - { - if (h.maxHp > 0.0f) - { - drawHealthBar(painter, coordinates, - bboxRect.left(), bboxRect.bottom() + 1.0, - bboxRect.width(), h.hp / h.maxHp, f.isEnemy); - } - }); - } - - // Selection highlights are drawn last, after every building and construction - // site fill, so a selected building surrounded by neighbours keeps its outline: - // the highlight sits 1px outside the footprint (into adjacent tiles), and drawing - // it inline would let later-drawn neighbours overpaint it with their body fill. - drawSelectionHighlights(painter, coordinates); -} - -std::optional GameWorldView::footprintWidgetRect( - const WorldCoordinates& coordinates, BuildingId id) const -{ - std::optional anchor; - std::optional footprint; - - if (const Building* b = findBuilding(m_sim->getFactoryState(), id)) - { - anchor = b->anchor; - footprint = b->footprint; - } - else if (const ConstructionSite* s = findSite(m_sim->getFactoryState(), id)) - { - anchor = s->anchor; - footprint = s->footprint; - } - if (!anchor.has_value() || !footprint.has_value()) { return std::nullopt; } - - const QPointF tl = coordinates.tileToWidget(*anchor); - return QRectF(tl.x(), tl.y(), - footprint->width() * static_cast(coordinates.getTilePx()), - footprint->height() * static_cast(coordinates.getTilePx())); -} - -void GameWorldView::drawSelectionHighlights(QPainter& painter, - const WorldCoordinates& coordinates) -{ - painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); - painter.setBrush(Qt::NoBrush); - - for (BuildingId selId : m_selection.getSelectedBuildings()) - { - const std::optional rect = footprintWidgetRect(coordinates, selId); - if (!rect.has_value()) { continue; } - // Outline sits 1px outside the footprint (into adjacent tiles). - painter.drawRect(rect->adjusted(-1, -1, 1, 1)); - } - - // A ring around each selected piece of debris, sitting just outside the debris's - // own rendered circle (REQ-UI-DEBRIS-CLICK-SELECT). - if (!m_selection.getSelectedDebris().empty()) - { - const qreal outlineRadius = - static_cast(getDebrisRadiusPx(coordinates)) + 3.0; - for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin())) - { - if (!m_selection.isDebrisSelected(debris.entity)) { continue; } - painter.drawEllipse(coordinates.worldToWidget(debris.position), - outlineRadius, outlineRadius); - } - } -} - -void GameWorldView::drawCopyConfigFeedback(QPainter& painter, - const WorldCoordinates& coordinates) -{ - const QColor color = m_visuals->overlays.copyConfig; - - // Eligible-target tint: while a configuration is cached, every same-type - // building and site (the source included) is a valid paste target and is - // washed in the copy-settings color (REQ-BLD-COPY-CONFIG-FEEDBACK). - if (m_copiedConfig.has_value()) - { - painter.setPen(Qt::NoPen); - painter.setBrush(color); - const BuildingType type = m_copiedConfig->type; - for (const Building& b : getAllBuildings(m_sim->getFactoryState())) - { - if (b.type != type) { continue; } - const std::optional rect = footprintWidgetRect(coordinates, b.id); - if (rect.has_value()) { painter.drawRect(*rect); } - } - for (const ConstructionSite& s : getAllSites(m_sim->getFactoryState())) - { - if (s.type != type) { continue; } - const std::optional rect = footprintWidgetRect(coordinates, s.id); - if (rect.has_value()) { painter.drawRect(*rect); } - } - } - - // Copy / paste flashes: a brief outline in the same color, drawn like the - // selection outline (REQ-BLD-COPY-CONFIG-FEEDBACK). - painter.setPen(QPen(color, 2)); - painter.setBrush(Qt::NoBrush); - for (const CopyConfigFlash& flash : m_copyConfigFlashes) - { - const std::optional rect = footprintWidgetRect(coordinates, flash.id); - if (rect.has_value()) { painter.drawRect(rect->adjusted(-1, -1, 1, 1)); } - } -} - -void GameWorldView::drawPortItems(QPainter& painter, const WorldCoordinates& coordinates) -{ - const float halfPx = coordinates.getTilePx() * 0.5f * 0.5f; - - // Port items are drawn over the buildings (drawBuildings runs first) but clipped - // to a thin margin at each machine's edges: the clip region is the whole view - // minus every machine's interior (its footprint inset by kPortMarginTiles). So a - // transiting item shows only near the port edge — appearing to emerge from / sink - // into the machine (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE) and staying - // visible in the ~2×margin band at a seam between two touching buildings - // (REQ-MAT-DIRECT-COUPLE). Transport tiles are not machines and never occlude, so - // items on belts stay fully visible. - constexpr double kPortMarginTiles = 0.2; - const double margin = kPortMarginTiles * static_cast(coordinates.getTilePx()); - - QRegion clip(rect()); - for (const Building& b : getAllBuildings(m_sim->getFactoryState())) - { - if (b.type == BuildingType::Belt || b.type == BuildingType::Splitter - || b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit) - { - continue; - } - const std::set cells(b.bodyCells.begin(), b.bodyCells.end()); - for (const QPoint& cell : b.bodyCells) - { - // Inset an edge only where the neighbouring cell is not part of the same - // building, so interior cell seams stay filled (handles L-shaped footprints). - const double l = cells.count(cell + QPoint(-1, 0)) ? 0.0 : margin; - const double t = cells.count(cell + QPoint( 0, -1)) ? 0.0 : margin; - const double r = cells.count(cell + QPoint( 1, 0)) ? 0.0 : margin; - const double d = cells.count(cell + QPoint( 0, 1)) ? 0.0 : margin; - clip = clip.subtracted( - QRegion(coordinates.tileRect(cell).adjusted(l, t, -r, -d).toRect())); - } - } - - // 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 QPointF center = coordinates.worldToWidget( - QVector2D(static_cast(worldPos.x()), - static_cast(worldPos.y()))); - drawWorldItem(painter, type.id, center, halfPx); - }; - - painter.save(); - painter.setClipRegion(clip); - m_sim->getBuildings().forEachEmergingItem(m_sim->getFactoryState(), drawItem); - m_sim->getBuildings().forEachIncomingItem(m_sim->getFactoryState(), drawItem); - 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 WorldCoordinates& coordinates) -{ - const float halfPx = coordinates.getTilePx() * 0.5f * 0.5f; - const QRect vr = coordinates.getViewportRect(); - - m_sim->getBelts().forEachVisualItem(vr, [&](const VisualItem& vi) - { - const QPointF center = coordinates.worldToWidget( - QVector2D(static_cast(vi.worldPos.x()), - static_cast(vi.worldPos.y()))); - drawWorldItem(painter, vi.type.id, center, halfPx); - }); -} - -void GameWorldView::drawDebris(QPainter& painter, const WorldCoordinates& coordinates) -{ - for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin())) - { - drawDebrisMarker(painter, coordinates, - coordinates.worldToWidget(debris.position)); - } -} - -void GameWorldView::drawStations(QPainter& painter, const WorldCoordinates& coordinates) -{ - m_sim->getAdmin().forEach( - [&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, - const HealthComponent& h) - { - const BuildingType visType = f.isEnemy - ? BuildingType::EnemyDefenceStation - : BuildingType::PlayerDefenceStation; - const std::map::const_iterator it = - m_visuals->buildings.find(visType); - if (it == m_visuals->buildings.end()) { return; } - const BuildingVisuals& bv = it->second; - - painter.setPen(Qt::NoPen); - for (const QPoint& cell : sb.bodyCells) - { - painter.fillRect(coordinates.tileRect(cell), bv.fill); - } - - const QPointF tl = - coordinates.tileToWidget(QPoint(sb.anchor.x(), sb.anchor.y())); - const QRectF bboxRect(tl.x(), tl.y(), - sb.footprint.width() * static_cast(coordinates.getTilePx()), - sb.footprint.height() * static_cast(coordinates.getTilePx())); - - painter.setPen(QPen(bv.outline, 1)); - painter.setBrush(Qt::NoBrush); - painter.drawRect(bboxRect); - - // Station icon over the fill (REQ-UI-WORLD-ICON); the same symbol is - // colored blue/red by the player/enemy fill it is drawn over. - drawBuildingIcon(painter, coordinates, visType, bboxRect, bv.fill); - - if (m_selection.isActorSelected(e)) - { - painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); - painter.setBrush(Qt::NoBrush); - painter.drawRect(bboxRect.adjusted(-2, -2, 2, 2)); - } - - // HP bar below footprint. - if (h.maxHp > 0.0f) - { - drawHealthBar(painter, coordinates, - bboxRect.left(), bboxRect.bottom() + 1.0, - bboxRect.width(), h.hp / h.maxHp, f.isEnemy); - } - }); -} - -void GameWorldView::drawShips(QPainter& painter, const WorldCoordinates& coordinates) -{ - const float forward = getShipForwardExtentPx(coordinates); - - m_sim->getAdmin().forEach( - [&](entt::entity e, const ShipIdentityComponent& si, - const PositionComponent& pos, const FacingComponent& facing, - const FactionComponent& fac, const HealthComponent& h) - { - const std::map::const_iterator it = - m_visuals->ships.find(si.schematicId); - if (it == m_visuals->ships.end()) { return; } - - const QPointF center = coordinates.worldToWidget(pos.value); - drawShipBody(painter, coordinates, center, facing.radians, - it->second.fill, it->second.outline); - - if (m_selection.isActorSelected(e)) - { - painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); - painter.setBrush(Qt::NoBrush); - const qreal r = static_cast(forward) + 2.0; - painter.drawEllipse(center, r, r); - } - - if (h.maxHp > 0.0f) - { - const qreal barW = static_cast(forward) * 2.0; - const qreal barX = center.x() - static_cast(forward); - const qreal barY = center.y() + static_cast(forward) + 1.0; - drawHealthBar(painter, coordinates, barX, barY, barW, - h.hp / h.maxHp, fac.isEnemy); - } - }); -} - -void GameWorldView::drawDebugSensorRanges(QPainter& painter, - const WorldCoordinates& coordinates) -{ - m_sim->getAdmin().forEach( - [&](entt::entity /*e*/, const ShipIdentityComponent& si, - const PositionComponent& pos, const FacingComponent& /*facing*/, - const FactionComponent& /*fac*/, const SensorRangeComponent& sensor) - { - const std::map::const_iterator it = - m_visuals->ships.find(si.schematicId); - if (it == m_visuals->ships.end()) { return; } - - drawSensorRange(painter, coordinates, - coordinates.worldToWidget(pos.value), - sensor.value_tiles, it->second.outline); - }); -} - -void GameWorldView::drawDebugTargetLines(QPainter& painter, - const WorldCoordinates& coordinates) -{ - // Draw a thin translucent line from a ship to a target, colored by the ship's - // own schematic fill. Shared by the attack, repair and salvage target lines. - const std::function - drawTargetLine = [&](const std::string& schematicId, const QVector2D& from, - const QVector2D& to) - { - const std::map::const_iterator it = - m_visuals->ships.find(schematicId); - if (it == m_visuals->ships.end()) { return; } - - QColor lineColor = it->second.fill; - lineColor.setAlpha(128); - painter.setPen(QPen(lineColor, 1)); - painter.drawLine(coordinates.worldToWidget(from), - coordinates.worldToWidget(to)); - }; - - m_sim->getAdmin().forEach( - [&](entt::entity /*e*/, const ShipIdentityComponent& si, - const PositionComponent& pos, const AttackBehavior& attack) - { - if (!attack.currentTarget.has_value()) { return; } - - const std::optional targetPos = - entityPosition(*attack.currentTarget); - if (!targetPos.has_value()) { return; } - - drawTargetLine(si.schematicId, pos.value, *targetPos); - }); - - m_sim->getAdmin().forEach( - [&](entt::entity /*e*/, const ShipIdentityComponent& si, - const PositionComponent& pos, const RepairBehavior& repair) - { - if (!repair.currentTarget.has_value()) { return; } - - const std::optional targetPos = - entityPosition(*repair.currentTarget); - if (!targetPos.has_value()) { return; } - - drawTargetLine(si.schematicId, pos.value, *targetPos); - }); - - m_sim->getAdmin().forEach( - [&](entt::entity /*e*/, const ShipIdentityComponent& si, - const PositionComponent& pos, const SalvageScrapBehavior& salvage) - { - if (!salvage.debrisTarget.has_value()) { return; } - - drawTargetLine(si.schematicId, pos.value, *salvage.debrisTarget); - }); -} - -void GameWorldView::drawDebugOverlay(QPainter& painter) -{ - painter.resetTransform(); - - const QStringList lines = { - tr("Accumulated Threat Level: %1") - .arg(m_sim->getThreatLevel(), 0, 'f', 1), - tr("Time until Wave: %1s") - .arg(ticksToSeconds(m_sim->getNormalGapRemainingTicks()), 0, 'f', 1), - tr("Threat Accumulation Rate: %1 threat/s") - .arg(m_sim->getThreatAccumulationRate(), 0, 'f', 1), - tr("Max Factory Production: %1 threat/s") - .arg(m_sim->getMaxFactoryProductionThreatRate(), 0, 'f', 1), - tr("Current Factory Production: %1 threat/s") - .arg(m_sim->getCurrentFactoryProductionThreatRate(), 0, 'f', 1), - }; - - QFont font = painter.font(); - font.setPointSize(m_visuals->toast.fontSize); - painter.setFont(font); - - const QFontMetrics fm = painter.fontMetrics(); - const int lineH = fm.height(); - const int padding = 8; - const int spacing = 4; - - int textW = 0; - for (const QString& line : lines) - { - textW = std::max(textW, fm.horizontalAdvance(line)); - } - const int bgW = textW + padding * 2; - const int bgH = lineH * lines.size() + spacing * (lines.size() - 1) + padding * 2; - - const QRect bgRect(padding, padding, bgW, bgH); - painter.fillRect(bgRect, QColor(0, 0, 0, 160)); - - painter.setPen(Qt::white); - int y = padding * 2; - for (const QString& line : lines) - { - const QRect textRect(padding * 2, y, textW, lineH); - painter.drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, line); - y += lineH + spacing; - } -} - -void GameWorldView::drawBeams(QPainter& painter, const WorldCoordinates& coordinates) -{ - const QPainter::RenderHints savedHints = painter.renderHints(); - painter.setRenderHint(QPainter::Antialiasing, true); - - for (const ActiveBeam& beam : m_activeBeams) - { - const std::optional shooterPos = entityPosition(beam.event.shooter); - const std::optional targetPos = entityPosition(beam.event.target); - if (!shooterPos.has_value() || !targetPos.has_value()) { continue; } - - QColor color = m_visuals->beams.weaponColor; - switch (beam.event.kind) - { - case BeamKind::Weapon: color = m_visuals->beams.weaponColor; break; - case BeamKind::Repair: color = m_visuals->beams.repairColor; break; - case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break; - } - - const QPointF s = coordinates.worldToWidget(*shooterPos); - const QPointF t = coordinates.worldToWidget(*targetPos + beam.targetOffset); - - // Unit direction/perpendicular of the beam in widget space. A degenerate - // zero-length beam (shooter and target coincide) has no direction to - // taper along, so skip it. - const QVector2D delta(static_cast(t.x() - s.x()), - static_cast(t.y() - s.y())); - const float lengthPx = delta.length(); - if (lengthPx < 0.001f) { continue; } - const QVector2D dir = delta / lengthPx; - const QVector2D perp(-dir.y(), dir.x()); - - // Directional taper: draw the beam as a quad that is wide at the shooter - // and narrows to a faint tip at the target, so it reads as an arrow - // pointing away from whoever fired it. Without this, a beam strung - // between two nearby ships is symmetric and gives no cue which end is the - // source (the readability problem this addresses). - const float widthPx = std::max(1.0f, static_cast(m_visuals->beams.widthPx)); - const float baseHalf = widthPx * 1.f; - const float tipHalf = widthPx * 0.35f; - - QPolygonF quad; - quad << QPointF(s.x() + static_cast(perp.x() * baseHalf), - s.y() + static_cast(perp.y() * baseHalf)) - << QPointF(s.x() - static_cast(perp.x() * baseHalf), - s.y() - static_cast(perp.y() * baseHalf)) - << QPointF(t.x() - static_cast(perp.x() * tipHalf), - t.y() - static_cast(perp.y() * tipHalf)) - << QPointF(t.x() + static_cast(perp.x() * tipHalf), - t.y() + static_cast(perp.y() * tipHalf)); - - QColor bright = color; - bright.setAlpha(255); - QColor faint = color; - faint.setAlpha(90); - - QLinearGradient bodyGrad(s, t); - bodyGrad.setColorAt(0.0, bright); - bodyGrad.setColorAt(1.0, faint); - - painter.setPen(Qt::NoPen); - painter.setBrush(bodyGrad); - painter.drawPolygon(quad); - } - - painter.setBrush(Qt::NoBrush); - painter.setRenderHints(savedHints); -} - -void GameWorldView::drawSelectedTunnelConnections(QPainter& painter, - const WorldCoordinates& coordinates) -{ - if (m_selection.getSelectedBuildings().empty()) { return; } - - const TunnelTileMap tunnels = collectTunnelTiles(); - if (tunnels.empty()) { return; } - - const TunnelLookup lookup = makeTunnelLookup(tunnels); - - // Collect the tiles to highlight in a set so a connection selected from both ends - // (or overlapping runs) is filled exactly once — filling a semi-transparent green - // twice would darken it (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). - std::set highlightTiles; - for (const BuildingId id : m_selection.getSelectedBuildings()) - { - std::optional anchor; - std::optional type; - Rotation rotation = Rotation::East; - if (const Building* b = findBuilding(m_sim->getFactoryState(), id)) - { - anchor = b->anchor; type = b->type; rotation = b->rotation; - } - else if (const ConstructionSite* s = findSite(m_sim->getFactoryState(), id)) - { - anchor = s->anchor; type = s->type; rotation = s->rotation; - } - if (!type.has_value() - || (*type != BuildingType::TunnelEntry && *type != BuildingType::TunnelExit)) - { - continue; - } - - const std::optional partner = findTunnelPartner( - lookup, *anchor, *type, rotation, m_config->world.tunnelMaxDistance_tiles); - if (!partner.has_value()) { continue; } - - // Add every tile from the selected end to its partner inclusive (colinear run). - const QPoint delta = *partner - *anchor; - const QPoint stepDir((delta.x() > 0) - (delta.x() < 0), - (delta.y() > 0) - (delta.y() < 0)); - for (QPoint t = *anchor; ; t += stepDir) - { - highlightTiles.insert(t); - if (t == *partner) { break; } - } - } - - const QColor green = m_visuals->overlays.tunnelPreview; - for (const QPoint& tile : highlightTiles) - { - painter.fillRect(coordinates.tileRect(tile), green); - } -} - -void GameWorldView::drawOverlays(QPainter& painter, const WorldCoordinates& coordinates) -{ - // Green connection highlight for any selected tunnel end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). - drawSelectedTunnelConnections(painter, coordinates); - - // Builder-mode ghost - if (m_buildMode.isBuilderMode()) - { - if (m_buildMode.getBuilderType() == BuildingType::Belt - && m_buildMode.isDraggingBelt()) - { - // Belt drag: a ghost per path tile (REQ-BLD-BELT-DRAG). Rotate-in-place - // and affordable new tiles use the belt colors; occupied/invalid tiles - // use the invalid color; unaffordable tiles show no ghost at all. - const std::vector resolved = resolveBeltDragPath(); - for (std::size_t index = 0; index < resolved.size(); ++index) - { - const BeltDragResolved& item = resolved[index]; - if (item.action == BeltTileAction::PlaceNew && !item.affordable) - { - continue; - } - const BeltPathTile& entry = m_buildMode.getBeltDragPath()[index]; - drawBuildingGhost(painter, coordinates, BuildingType::Belt, - entry.tile, entry.rotation, - /*valid*/ item.action != BeltTileAction::Invalid, - /*showPortTargetGlyphs*/ true); - } - } - else - { - // In tunnel mode the ghost shows the position-resolved type (entry or - // exit) and, when it would complete an existing tunnel, the matched end - // and the tiles between it and the ghost are tinted green - // (REQ-BLD-TUNNEL-MODE). - const QPoint ghostTile = m_buildMode.getGhostTile(); - const std::optional& partnerTile = m_buildMode.getTunnelPartnerTile(); - if (m_buildMode.isTunnelMode() && m_buildMode.isGhostValid() - && partnerTile.has_value()) - { - const QColor green = m_visuals->overlays.tunnelPreview; - painter.fillRect(coordinates.tileRect(*partnerTile), green); - - // Partner and ghost tile are colinear along the tunnel run; tint the - // tiles strictly between them. - const QPoint delta = *partnerTile - ghostTile; - const QPoint step((delta.x() > 0) - (delta.x() < 0), - (delta.y() > 0) - (delta.y() < 0)); - for (QPoint t = ghostTile + step; t != *partnerTile; t += step) - { - painter.fillRect(coordinates.tileRect(t), green); - } - } - - drawBuildingGhost(painter, coordinates, - m_buildMode.getEffectiveBuilderType(), - ghostTile, m_buildMode.getGhostRotation(), - m_buildMode.isGhostValid(), - /*showPortTargetGlyphs*/ true); - } - } - - // Blueprint placement ghost - if (m_buildMode.isBlueprintMode()) - { - for (const BlueprintBuilding& bb : m_buildMode.getBlueprint().buildings) - { - // Locked building types are omitted from the blueprint (REQ-LOCK-BUILDING, - // REQ-LOCK-UI-BLUEPRINT), so they are not ghosted either. - if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } - const QPoint anchor = m_buildMode.getBlueprintGhostTile() + bb.offset; - const bool valid = canPlaceBuildingHere(bb.type, anchor, bb.rotation); - drawBuildingGhost(painter, coordinates, bb.type, anchor, bb.rotation, - valid, /*showPortTargetGlyphs*/ false); - } - } - - // Queued for deconstruction: tint every building currently in the - // deconstruction queue, regardless of mode (REQ-BLD-DECON-QUEUE). - for (const Building& b : getAllBuildings(m_sim->getFactoryState())) - { - if (!b.queuedForDeconstruction) { continue; } - for (const QPoint& cell : b.bodyCells) - { - painter.fillRect(coordinates.tileRect(cell), - m_visuals->overlays.deconstructTint); - } - } - - // Deconstruct tint: while dragging a deconstruct box, tint every covered - // building/site (REQ-BLD-DECONSTRUCT-BOX); otherwise tint the hovered one. - if (m_buildMode.isDeconstructMode() && m_boxSelecting) - { - for (BuildingId id : buildingsInBox(m_boxStartTile, m_boxCurrentTile)) - { - const Building* b = findBuilding(m_sim->getFactoryState(), id); - if (b && b->type == BuildingType::Hq) { continue; } - const std::vector* cells = nullptr; - const ConstructionSite* s = nullptr; - if (b) { cells = &b->bodyCells; } - else if ((s = findSite(m_sim->getFactoryState(), id))) { cells = &s->bodyCells; } - if (cells) - { - for (const QPoint& cell : *cells) - { - painter.fillRect(coordinates.tileRect(cell), - m_visuals->overlays.deconstructTint); - } - } - } - } - else if (m_buildMode.isDeconstructMode() - && m_buildMode.getDeconstructHoverBuildingId().has_value()) - { - const Building* b = findBuilding(m_sim->getFactoryState(), - *m_buildMode.getDeconstructHoverBuildingId()); - if (b) - { - for (const QPoint& cell : b->bodyCells) - { - painter.fillRect(coordinates.tileRect(cell), - m_visuals->overlays.deconstructTint); - } - } - } - - // Box-select rectangle - if (m_boxSelecting) - { - const QPoint tl(std::min(m_boxStartTile.x(), m_boxCurrentTile.x()), - std::min(m_boxStartTile.y(), m_boxCurrentTile.y())); - const QPoint br(std::max(m_boxStartTile.x(), m_boxCurrentTile.x()) + 1, - std::max(m_boxStartTile.y(), m_boxCurrentTile.y()) + 1); - const QRectF selRect(coordinates.tileToWidget(tl), - coordinates.tileToWidget(br)); - painter.setPen(QPen(m_visuals->overlays.selectionRect, 1)); - painter.setBrush(Qt::NoBrush); - painter.drawRect(selRect); - } -} - -void GameWorldView::drawBuildingGhost(QPainter& painter, - const WorldCoordinates& coordinates, - BuildingType type, - QPoint anchorTile, Rotation rotation, - bool valid, bool showPortTargetGlyphs) -{ - const BuildingDef* def = m_config->buildings.findBuildingDef(type); - if (!def) { return; } - - const std::map::const_iterator it = - m_visuals->buildings.find(type); - if (it == m_visuals->buildings.end()) { return; } - const BuildingVisuals& bv = it->second; - - // Valid ghosts show the building type's own colors; invalid ghosts override - // with the distinct invalid color (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID). The - // invalid color's RGB is taken at full opacity so it does not double-dim - // against the setOpacity below (the configured color carries its own alpha). - const QColor invalidColor(m_visuals->overlays.ghostInvalid.red(), - m_visuals->overlays.ghostInvalid.green(), - m_visuals->overlays.ghostInvalid.blue()); - const QColor fillColor = valid ? bv.fill : invalidColor; - const QColor lineColor = valid ? bv.outline : invalidColor; - - const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation); - if (parsed.bodyCells.empty()) { return; } - - painter.setOpacity(0.5); - - QPoint minCell = parsed.bodyCells.front(); - QPoint maxCell = parsed.bodyCells.front(); - for (const QPoint& cell : parsed.bodyCells) - { - painter.fillRect(coordinates.tileRect(anchorTile + cell), fillColor); - minCell.setX(std::min(minCell.x(), cell.x())); - minCell.setY(std::min(minCell.y(), cell.y())); - maxCell.setX(std::max(maxCell.x(), cell.x())); - maxCell.setY(std::max(maxCell.y(), cell.y())); - } - - const QPointF tl = coordinates.tileToWidget(anchorTile + minCell); - const QRectF bboxRect(tl.x(), tl.y(), - (maxCell.x() - minCell.x() + 1) * static_cast(coordinates.getTilePx()), - (maxCell.y() - minCell.y() + 1) * static_cast(coordinates.getTilePx())); - - painter.setPen(QPen(lineColor, 1)); - painter.setBrush(Qt::NoBrush); - painter.drawRect(bboxRect); - - // Icon glyph over the ghost fill (REQ-UI-WORLD-ICON); an invalid ghost's fill - // is the red invalid color, which auto-contrasts to a white icon. - if (!drawBuildingIcon(painter, coordinates, type, bboxRect, fillColor) - && !bv.glyph.isEmpty()) - { - painter.setPen(lineColor); - painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph); - } - - for (const Port& port : parsed.outputPorts) - { - drawPortGlyph(painter, coordinates, - anchorTile + outputBodyTile(port.tile, port.direction), - port.direction, lineColor, /*centered*/ false); - } - - // REQ-UI-PORT-TARGET-GLYPH: while in builder mode, additionally mark each - // output port's target cell (the cell just outside the footprint the port - // pushes into) with a directional glyph, previewing where output will flow. - // The Tunnel Entry is excluded — it receives items from any of its non-mouth - // edges rather than emitting into a single adjacent cell. The Shipyard is - // excluded too — it spawns a ship rather than emitting a belt item. - if (showPortTargetGlyphs - && type != BuildingType::TunnelEntry - && type != BuildingType::Shipyard) - { - for (const Port& port : parsed.outputPorts) - { - drawPortGlyph(painter, coordinates, anchorTile + port.tile, - port.direction, lineColor, /*centered*/ true); - } - } - - painter.setOpacity(1.0); -} void GameWorldView::drawScreenSpace(QPainter& /*painter*/) { @@ -2346,7 +1125,7 @@ void GameWorldView::selectInBox(bool additive) const SelectionMode mode = additive ? SelectionMode::Add : SelectionMode::Replace; const std::vector boxIds = - buildingsInBox(m_boxStartTile, m_boxCurrentTile); + buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile); if (!boxIds.empty()) { m_selection.selectBuildings(boxIds, mode); @@ -2426,7 +1205,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) m_boxSelecting = false; const std::vector boxIds = - buildingsInBox(m_boxStartTile, m_boxCurrentTile); + buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile); if (m_buildMode.isDeconstructMode()) { diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 88b8f12..1df2380 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -61,6 +61,7 @@ #include "VisualsConfig.h" #include "WorldCamera.h" #include "WorldCoordinates.h" +#include "WorldRenderer.h" struct Command; struct ParsedReplay; @@ -68,19 +69,6 @@ class ItemIconCache; class ReplayPlayer; class Simulation; class QPainter; -class QSvgRenderer; - -struct QPointCompare -{ - bool operator()(const QPoint& a, const QPoint& b) const - { - if (a.x() != b.x()) { return a.x() < b.x(); } - return a.y() < b.y(); - } -}; - -// Tunnel entries/exits indexed by their single-cell tile (REQ-BLD-TUNNEL-MODE). -using TunnelTileMap = std::map; class GameWorldView : public QOpenGLWidget, public CombinedEventHandler widget transform for the current viewport size and scroll // position. Cheap to build and deliberately not cached: it is a snapshot that // a resize or a scroll invalidates, so every user takes a fresh one. WorldCoordinates getCoordinates() const; - // Widget-space rectangle covering a building or construction site's footprint, - // or nullopt if the id resolves to neither. Shared by the selection highlight - // and the copy-settings feedback (REQ-BLD-COPY-CONFIG-FEEDBACK). - std::optional footprintWidgetRect(const WorldCoordinates& coordinates, - BuildingId id) const; - float getAsteroidLeftEdge() const; float getEnemyStationRightEdge() const; // The camera's current pan limits, read fresh from the simulation each frame: @@ -218,36 +182,9 @@ private: bool canPlaceBuildingHere(BuildingType type, QPoint anchor, Rotation rot) const; std::optional buildingAtTile(QPoint tile) const; std::optional siteAtTile(QPoint tile) const; - // Ids of all buildings and construction sites whose footprint intersects - // the tile box spanned by the two (unordered) corner tiles. - std::vector buildingsInBox(QPoint cornerA, QPoint cornerB) const; - - void drawPortGlyph(QPainter& painter, const WorldCoordinates& coordinates, - QPoint tile, Rotation direction, const QColor& color, - bool centered); - - void drawBuildingGhost(QPainter& painter, const WorldCoordinates& coordinates, - BuildingType type, - QPoint anchorTile, Rotation rotation, bool valid, - bool showPortTargetGlyphs); - - // Loads the per-building world icons (REQ-UI-WORLD-ICON) from - // /../icons/buildings once at construction. Only the building - // types with a world icon are loaded (production buildings, HQ, stations); - // belts, splitters, and tunnels are deliberately excluded so their - // orientation stays readable. The SVG's chip background is stripped; the - // glyph is pre-rendered in both white and dark ink for auto-contrast. - void loadBuildingIcons(const std::string& configDir); - // Draws a building's world icon glyph centered in box, choosing the white or - // dark pre-rendered variant by fill luminance so it stays legible. Returns - // false if the type has no world icon (caller falls back to the text glyph). - bool drawBuildingIcon(QPainter& painter, const WorldCoordinates& coordinates, - BuildingType type, - const QRectF& box, const QColor& fill) const; void placeBlueprintAtTile(QPoint center); - std::optional entityPosition(entt::entity entity) const; // Drops despawned or fully-collected debris from the selection // (REQ-UI-DEBRIS-CLICK-SELECT). Called each frame from onFrame(). void pruneDespawnedDebris(); @@ -266,17 +203,6 @@ private: // ghost tile, rotation, and sub-tile cursor position, storing both on the build // mode controller. Only meaningful in tunnel mode (REQ-BLD-TUNNEL-MODE). void updateTunnelGhost(); - // Indexes every tunnel entry/exit — built or still a construction site — by its - // single-cell tile. Shared by the placement preview and the selection highlight. - TunnelTileMap collectTunnelTiles() const; - // Wraps a tunnel tile index in the lookup functor the TunnelCompletion helpers - // take. The returned functor references `tunnels`, which must outlive it. - static TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels); - // Draws the green connection highlight for every selected tunnel end that has a - // matching end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). - void drawSelectedTunnelConnections(QPainter& painter, - const WorldCoordinates& coordinates); - // Belt drag placement (REQ-BLD-BELT-DRAG). // Recomputes the drag path from its anchor to cursorTile using the current ghost // orientation, and stores it on the build mode controller. @@ -297,12 +223,6 @@ private: // The mode transitions themselves live on m_buildMode. void rotateGhost(bool clockwise); - struct ActiveBeam - { - BeamFiredEvent event; - QVector2D targetOffset; - }; - // Beam lifetime in game ticks so beams freeze with the simulation when // paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM). static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3); @@ -311,22 +231,6 @@ private: const GameConfig* m_config; const VisualsConfig* m_visuals; - // World icon glyph renderers per building type (REQ-UI-WORLD-ICON), in a - // white and a dark variant so drawBuildingIcon can auto-contrast against the - // building's fill. Rendered as vector at the view scale each draw so they - // stay crisp. Populated once by loadBuildingIcons(). - struct BuildingIconRenderers - { - std::unique_ptr white; - std::unique_ptr dark; - }; - std::map m_buildingIcons; - - // Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by - // MainWindow. Shared draw path for belt and port items; pixmaps are cached - // per target size. - ItemIconCache* m_itemIcons; - // Funnels all player input into the single Simulation::apply chokepoint. CommandManager m_commandManager; // A Reset command was enqueued; reset the view after the next drain applies it. @@ -335,6 +239,10 @@ private: // live input is ignored (the CommandManager is in replay mode). std::unique_ptr m_replayPlayer; + // Draws the world; this widget supplies the interaction state each frame and + // keeps only the screen-anchored chrome for itself. + std::unique_ptr m_renderer; + TickDriver m_tickDriver; QElapsedTimer m_frameTimer; std::mt19937 m_rng; @@ -366,11 +274,6 @@ private: // pasted onto it (REQ-BLD-COPY-CONFIG-FEEDBACK). remainingMs counts down in // wall-clock time so the flash plays at a fixed length regardless of game speed // (and while paused). - struct CopyConfigFlash - { - BuildingId id; - qint64 remainingMs; - }; std::vector m_copyConfigFlashes; static constexpr qint64 kCopyFlashDurationMs = 300; diff --git a/src/ui/WorldRenderer.cpp b/src/ui/WorldRenderer.cpp new file mode 100644 index 0000000..a1b1cb2 --- /dev/null +++ b/src/ui/WorldRenderer.cpp @@ -0,0 +1,1219 @@ +#include "WorldRenderer.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "AttackBehavior.h" +#include "BeltSystem.h" +#include "Building.h" +#include "BuildingSystem.h" +#include "DebrisComponent.h" +#include "DebrisSystem.h" +#include "FacingComponent.h" +#include "FactionComponent.h" +#include "FactoryQueries.h" +#include "HealthComponent.h" +#include "HqProxyComponent.h" +#include "ItemIconCache.h" +#include "PlacementRules.h" +#include "PortGeometry.h" +#include "PositionComponent.h" +#include "ProductionRules.h" +#include "RepairBehavior.h" +#include "SalvageScrapBehavior.h" +#include "SensorRangeComponent.h" +#include "ShipIdentityComponent.h" +#include "Simulation.h" +#include "StationBodyComponent.h" +#include "SurfaceMask.h" +#include "TunnelCompletion.h" +#include "WorldPrimitives.h" + +namespace +{ + +// --- World building icons (REQ-UI-WORLD-ICON) -------------------------------- + +// Building types that render with a world icon, and the icon file name for each. +// Belts, splitters, and tunnels are intentionally absent so their orientation +// stays readable; the two defence stations share one symbol (colored per side by +// the fill it is drawn over). +struct WorldIconEntry { BuildingType type; const char* file; }; +const WorldIconEntry kWorldIconFiles[] = { + { BuildingType::Miner, "miner.svg" }, + { BuildingType::Smelter, "smelter.svg" }, + { BuildingType::Assembler, "assembler.svg" }, + { BuildingType::ReprocessingPlant, "reprocessing_plant.svg" }, + { BuildingType::Shipyard, "shipyard.svg" }, + { BuildingType::SalvageBay, "salvage_bay.svg" }, + { BuildingType::Hq, "hq.svg" }, + { BuildingType::PlayerDefenceStation, "station.svg" }, + { BuildingType::EnemyDefenceStation, "station.svg" }, +}; + +// On-screen size of every world icon, as a multiple of one tile (REQ-UI-WORLD-ICON): +// a little over one tile so all buildings' icons read at the same size regardless +// of footprint. +const qreal kWorldIconTileFactor = 1.25; + +// Produces an icon SVG containing only the glyph (the chip background rect +// stripped), with the white glyph stroke recolored to inkHex. +QByteArray worldIconSvg(const QByteArray& svg, const QString& inkHex) +{ + QString s = QString::fromUtf8(svg); + // Drop the full-canvas chip rect only; some glyphs use their own + // elements (e.g. the miner's drill housing, the hq's base), so match the + // 100x100 background rect specifically. + static const QRegularExpression backgroundRect( + QStringLiteral("]*>")); + s.remove(backgroundRect); + s.replace(QStringLiteral("#ffffff"), inkHex); + return s.toUtf8(); +} + +// Perceived luminance test; picks a dark glyph on light fills so it stays legible. +bool isLightFill(const QColor& c) +{ + const double lum = (0.299 * c.red() + 0.587 * c.green() + 0.114 * c.blue()) / 255.0; + return lum > 0.6; +} + +// Fill color for a building's status light per its production state +// (REQ-UI-STATUS-LIGHT). +QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl) +{ + switch (status) + { + case ProductionStatus::Unconfigured: return sl.grey; + case ProductionStatus::Producing: return sl.green; + case ProductionStatus::Starved: return sl.red; + case ProductionStatus::Blocked: return sl.yellow; + } + return sl.grey; +} + +} // namespace + +WorldRenderer::WorldRenderer(Simulation& sim, const VisualsConfig& visuals, + ItemIconCache* itemIcons, const std::string& configDir) + : m_sim(sim) + , m_visuals(visuals) + , m_itemIcons(itemIcons) +{ + loadBuildingIcons(configDir); +} + +WorldRenderer::~WorldRenderer() = default; + +void WorldRenderer::render(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame) +{ + drawTiles(painter, coordinates, frame); + drawBuildings(painter, coordinates, frame); + // Port items are drawn over the buildings but clipped to a thin margin at each + // machine's edges (see drawPortItems), so items appear to emerge from / sink + // into the port and stay visible while crossing directly between two touching + // buildings (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE, REQ-MAT-DIRECT-COUPLE). + drawPortItems(painter, coordinates, frame); + drawCopyConfigFeedback(painter, coordinates, frame); + drawStations(painter, coordinates, frame); + drawBeltItems(painter, coordinates, frame); + drawDebris(painter, coordinates, frame); + if (frame.isDebugDrawEnabled) + { + drawDebugSensorRanges(painter, coordinates, frame); + drawDebugTargetLines(painter, coordinates, frame); + drawDebugOverlay(painter); + } + drawShips(painter, coordinates, frame); + drawBeams(painter, coordinates, frame); + drawOverlays(painter, coordinates, frame); +} + +std::optional WorldRenderer::entityPosition(entt::entity entity) const +{ + if (!m_sim.getAdmin().isValid(entity) || !m_sim.getAdmin().hasAll(entity)) + { + return std::nullopt; + } + return m_sim.getAdmin().get(entity).value; +} + +void WorldRenderer::drawPortGlyph(QPainter& painter, + const WorldCoordinates& coordinates, QPoint tile, + Rotation direction, const QColor& color, + bool centered) +{ + const float px = coordinates.getTilePx(); + const QRectF tr = coordinates.tileRect(tile); + const QPointF center(tr.x() + static_cast(px) * 0.5, + tr.y() + static_cast(px) * 0.5); + + QPointF edgeOffset; + const char* ch; + switch (direction) + { + case Rotation::East: edgeOffset = QPointF(px * 0.25f, 0); ch = ">"; break; + case Rotation::West: edgeOffset = QPointF(-px * 0.25f, 0); ch = "<"; break; + case Rotation::North: edgeOffset = QPointF(0, -px * 0.25f); ch = "^"; break; + case Rotation::South: edgeOffset = QPointF(0, px * 0.25f); ch = "v"; break; + default: return; + } + + // Centered glyphs sit in the middle of the tile (used for a port's target + // cell, REQ-UI-PORT-TARGET-GLYPH) and are drawn 150% larger to stand out; + // otherwise offset toward the exit edge of the port's body tile + // (REQ-UI-PORT-GLYPH). + const QPointF offset = centered ? QPointF(0.0, 0.0) : edgeOffset; + const qreal glyphScale = centered ? 1.5 : 1.0; + + const qreal half = static_cast(px) * 0.3 * glyphScale; + const QPointF pos = center + offset; + const QRectF textRect(pos.x() - half, pos.y() - half, half * 2.0, half * 2.0); + + QFont f = painter.font(); + f.setPixelSize(std::max(6, static_cast(px * 0.4f * glyphScale))); + painter.setFont(f); + painter.setPen(color); + painter.drawText(textRect, Qt::AlignCenter, QString::fromLatin1(ch)); +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +void WorldRenderer::drawTiles(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& /*frame*/) +{ + const int leftTile = static_cast(std::floor(coordinates.getViewLeftTiles())) - 1; + const int rightTile = leftTile + + static_cast(std::ceil(coordinates.getViewportWidthTiles())) + 2; + const int bottomTile = m_sim.getConfig().world.heightTiles; + + // Asteroid columns left of the buildable edge are not yet unlocked by + // expansion; tint them so the player sees the reachable-but-locked area. + const int buildableLeftX = -m_sim.getCurrentAsteroidWidth_tiles(); + + painter.setPen(Qt::NoPen); + for (int x = leftTile; x <= rightTile; ++x) + { + const QColor& fill = (x < 0) + ? m_visuals.asteroid.fill + : m_visuals.space.fill; + const bool locked = (x < buildableLeftX); + for (int y = 0; y < bottomTile; ++y) + { + const QRectF rect = coordinates.tileRect(QPoint(x, y)); + painter.fillRect(rect, fill); + if (locked) + { + painter.fillRect(rect, m_visuals.overlays.lockedAsteroid); + } + } + } +} + +void WorldRenderer::loadBuildingIcons(const std::string& configDir) +{ + // Icons live beside the config dir, read at runtime like visuals.toml + // (REQ-UI-WORLD-ICON), mirroring how MainWindow derives the same path. + const QString iconDir = QDir::cleanPath( + QString::fromStdString(configDir) + "/../icons/buildings"); + + for (const WorldIconEntry& entry : kWorldIconFiles) + { + QFile file(iconDir + "/" + QString::fromLatin1(entry.file)); + if (!file.open(QIODevice::ReadOnly)) + { + continue; // A missing icon is not an error; the text glyph is used. + } + const QByteArray svg = file.readAll(); + BuildingIconRenderers renderers; + renderers.white = std::make_unique( + worldIconSvg(svg, QStringLiteral("#ffffff"))); + renderers.dark = std::make_unique( + worldIconSvg(svg, QStringLiteral("#1c1c20"))); + m_buildingIcons[entry.type] = std::move(renderers); + } +} + +bool WorldRenderer::drawBuildingIcon(QPainter& painter, + const WorldCoordinates& coordinates, + BuildingType type, + const QRectF& box, const QColor& fill) const +{ + const std::map::const_iterator it = + m_buildingIcons.find(type); + if (it == m_buildingIcons.end()) { return false; } + + // Every icon is the same fixed on-screen size, centered on the footprint, + // regardless of footprint size (REQ-UI-WORLD-ICON). + const qreal side = static_cast(coordinates.getTilePx()) * kWorldIconTileFactor; + const QRectF target(box.center().x() - side / 2.0, + box.center().y() - side / 2.0, side, side); + QSvgRenderer* renderer = isLightFill(fill) ? it->second.dark.get() + : it->second.white.get(); + + // Render the glyph as vector at the view scale so it stays crisp (a + // pre-rasterized pixmap downscaled to tile size looked blurry). + const bool wasAntialiasing = painter.testRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer->render(&painter, target); + painter.setRenderHint(QPainter::Antialiasing, wasAntialiasing); + return true; +} + +void WorldRenderer::drawBuildings(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame) +{ + for (const Building& b : getAllBuildings(m_sim.getFactoryState())) + { + const std::map::const_iterator it = + m_visuals.buildings.find(b.type); + if (it == m_visuals.buildings.end()) { continue; } + const BuildingVisuals& bv = it->second; + + painter.setPen(Qt::NoPen); + for (const QPoint& cell : b.bodyCells) + { + painter.fillRect(coordinates.tileRect(cell), bv.fill); + } + + const QPointF tl = coordinates.tileToWidget(b.anchor); + const QRectF bboxRect(tl.x(), tl.y(), + b.footprint.width() * static_cast(coordinates.getTilePx()), + b.footprint.height() * static_cast(coordinates.getTilePx())); + + painter.setPen(QPen(bv.outline, 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(bboxRect); + + // Icon glyph over the fill (REQ-UI-WORLD-ICON); falls back to the text + // glyph for building types without a world icon (e.g. tunnels). + if (!drawBuildingIcon(painter, coordinates, b.type, bboxRect, bv.fill) + && !bv.glyph.isEmpty()) + { + painter.setPen(bv.outline); + painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph); + } + + for (const Port& port : b.outputPorts) + { + drawPortGlyph(painter, coordinates, + outputBodyTile(port.tile, port.direction), + port.direction, bv.outline, /*centered*/ false); + } + + // Status light: a small circle in the building's upper-right corner + // (in default/East orientation), anchored to that footprint corner and + // rotating with the building (REQ-UI-STATUS-LIGHT). All status-light + // building footprints are rectangular, so a corner of the axis-aligned + // bounding box is the true corner. + if (const std::optional status = + getProductionStatus(m_sim.getConfig(), b)) + { + const float px = coordinates.getTilePx(); + const float r = px * 0.18f; + const float inset = r + px * 0.12f; + // Default orientation (East) puts the light at the top-right corner; + // clockwise rotation carries it around the footprint. + QPointF center(bboxRect.right() - inset, bboxRect.top() + inset); + switch (b.rotation) + { + case Rotation::East: break; + case Rotation::South: center = QPointF(bboxRect.right() - inset, + bboxRect.bottom() - inset); break; + case Rotation::West: center = QPointF(bboxRect.left() + inset, + bboxRect.bottom() - inset); break; + case Rotation::North: center = QPointF(bboxRect.left() + inset, + bboxRect.top() + inset); break; + } + painter.setBrush(statusLightFill(*status, m_visuals.statusLight)); + painter.setPen(QPen(m_visuals.statusLight.outline, 1)); + painter.drawEllipse(center, r, r); + } + } + + painter.setOpacity(0.5); + for (const ConstructionSite& s : getAllSites(m_sim.getFactoryState())) + { + const std::map::const_iterator it = + m_visuals.buildings.find(s.type); + if (it == m_visuals.buildings.end()) { continue; } + const BuildingVisuals& bv = it->second; + + for (const QPoint& cell : s.bodyCells) + { + painter.fillRect(coordinates.tileRect(cell), bv.fill); + } + + const QPointF tl = coordinates.tileToWidget(s.anchor); + const QRectF bboxRect(tl.x(), tl.y(), + s.footprint.width() * static_cast(coordinates.getTilePx()), + s.footprint.height() * static_cast(coordinates.getTilePx())); + painter.setPen(QPen(bv.outline, 1, Qt::DashLine)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(bboxRect); + + const BuildingDef* siteDef = m_sim.getConfig().buildings.findBuildingDef(s.type); + if (siteDef) + { + // Glyph + progress percentage + const Tick durationTicks = secondsToTicks(siteDef->constructionTimeSeconds); + int pct = 0; + if (s.completesAt > 0 && durationTicks > 0) + { + const Tick elapsed = m_sim.getCurrentTick() + - (s.completesAt - durationTicks); + pct = static_cast( + std::max(Tick(0), std::min(durationTicks, elapsed)) + * 100 / durationTicks); + } + const QString pctText = QString::number(pct) + "%"; + + painter.setPen(bv.outline); + // Identity symbol with the progress percentage below it + // (REQ-UI-CONSTRUCTION-PROGRESS): the world icon centered on the + // footprint where the type has one, otherwise the text glyph. + if (drawBuildingIcon(painter, coordinates, s.type, bboxRect, bv.fill)) + { + painter.drawText(bboxRect, Qt::AlignHCenter | Qt::AlignBottom, pctText); + } + else if (!bv.glyph.isEmpty()) + { + const QRectF topHalf(bboxRect.x(), bboxRect.y(), + bboxRect.width(), bboxRect.height() * 0.5); + const QRectF botHalf(bboxRect.x(), + bboxRect.y() + bboxRect.height() * 0.5, + bboxRect.width(), bboxRect.height() * 0.5); + painter.drawText(topHalf, Qt::AlignCenter, bv.glyph); + painter.drawText(botHalf, Qt::AlignCenter, pctText); + } + else + { + painter.drawText(bboxRect, Qt::AlignCenter, pctText); + } + + // Port glyphs + const ParsedSurfaceMask siteMask = + parseSurfaceMask(siteDef->surfaceMask, s.rotation); + for (const Port& port : siteMask.outputPorts) + { + const QPoint absBody = s.anchor + + outputBodyTile(port.tile, port.direction); + drawPortGlyph(painter, coordinates, absBody, port.direction, + bv.outline, /*centered*/ false); + } + } + } + painter.setOpacity(1.0); + + // HP bar below the HQ footprint; the HQ's HP lives on its proxy entity. Drawn + // after every building and construction site fill so a belt (or other tile) + // placed directly below the HQ cannot overpaint the bar (REQ-UI-STATUS-LIGHT + // neighbours case, same rationale as the selection highlights below). + for (const Building& b : getAllBuildings(m_sim.getFactoryState())) + { + if (b.type != BuildingType::Hq) { continue; } + const QPointF tl = coordinates.tileToWidget(b.anchor); + const QRectF bboxRect(tl.x(), tl.y(), + b.footprint.width() * static_cast(coordinates.getTilePx()), + b.footprint.height() * static_cast(coordinates.getTilePx())); + m_sim.getAdmin().forEach( + [&](entt::entity /*e*/, const HqProxyComponent& /*hq*/, + const FactionComponent& f, const HealthComponent& h) + { + if (h.maxHp > 0.0f) + { + drawHealthBar(painter, coordinates, + bboxRect.left(), bboxRect.bottom() + 1.0, + bboxRect.width(), h.hp / h.maxHp, f.isEnemy); + } + }); + } + + // Selection highlights are drawn last, after every building and construction + // site fill, so a selected building surrounded by neighbours keeps its outline: + // the highlight sits 1px outside the footprint (into adjacent tiles), and drawing + // it inline would let later-drawn neighbours overpaint it with their body fill. + drawSelectionHighlights(painter, coordinates, frame); +} + +std::optional WorldRenderer::footprintWidgetRect( + const WorldCoordinates& coordinates, BuildingId id) const +{ + std::optional anchor; + std::optional footprint; + + if (const Building* b = findBuilding(m_sim.getFactoryState(), id)) + { + anchor = b->anchor; + footprint = b->footprint; + } + else if (const ConstructionSite* s = findSite(m_sim.getFactoryState(), id)) + { + anchor = s->anchor; + footprint = s->footprint; + } + if (!anchor.has_value() || !footprint.has_value()) { return std::nullopt; } + + const QPointF tl = coordinates.tileToWidget(*anchor); + return QRectF(tl.x(), tl.y(), + footprint->width() * static_cast(coordinates.getTilePx()), + footprint->height() * static_cast(coordinates.getTilePx())); +} + +void WorldRenderer::drawSelectionHighlights(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame) +{ + painter.setPen(QPen(m_visuals.overlays.selectedOutline, 2)); + painter.setBrush(Qt::NoBrush); + + for (BuildingId selId : frame.selection.getSelectedBuildings()) + { + const std::optional rect = footprintWidgetRect(coordinates, selId); + if (!rect.has_value()) { continue; } + // Outline sits 1px outside the footprint (into adjacent tiles). + painter.drawRect(rect->adjusted(-1, -1, 1, 1)); + } + + // A ring around each selected piece of debris, sitting just outside the debris's + // own rendered circle (REQ-UI-DEBRIS-CLICK-SELECT). + if (!frame.selection.getSelectedDebris().empty()) + { + const qreal outlineRadius = + static_cast(getDebrisRadiusPx(coordinates)) + 3.0; + for (const DebrisInfo& debris : getAllDebrisInfo(m_sim.getAdmin())) + { + if (!frame.selection.isDebrisSelected(debris.entity)) { continue; } + painter.drawEllipse(coordinates.worldToWidget(debris.position), + outlineRadius, outlineRadius); + } + } +} + +void WorldRenderer::drawCopyConfigFeedback(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame) +{ + const QColor color = m_visuals.overlays.copyConfig; + + // Eligible-target tint: while a configuration is cached, every same-type + // building and site (the source included) is a valid paste target and is + // washed in the copy-settings color (REQ-BLD-COPY-CONFIG-FEEDBACK). + if (frame.copiedConfig.has_value()) + { + painter.setPen(Qt::NoPen); + painter.setBrush(color); + const BuildingType type = frame.copiedConfig->type; + for (const Building& b : getAllBuildings(m_sim.getFactoryState())) + { + if (b.type != type) { continue; } + const std::optional rect = footprintWidgetRect(coordinates, b.id); + if (rect.has_value()) { painter.drawRect(*rect); } + } + for (const ConstructionSite& s : getAllSites(m_sim.getFactoryState())) + { + if (s.type != type) { continue; } + const std::optional rect = footprintWidgetRect(coordinates, s.id); + if (rect.has_value()) { painter.drawRect(*rect); } + } + } + + // Copy / paste flashes: a brief outline in the same color, drawn like the + // selection outline (REQ-BLD-COPY-CONFIG-FEEDBACK). + painter.setPen(QPen(color, 2)); + painter.setBrush(Qt::NoBrush); + for (const CopyConfigFlash& flash : frame.copyConfigFlashes) + { + const std::optional rect = footprintWidgetRect(coordinates, flash.id); + if (rect.has_value()) { painter.drawRect(rect->adjusted(-1, -1, 1, 1)); } + } +} + +void WorldRenderer::drawPortItems(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& /*frame*/) +{ + const float halfPx = coordinates.getTilePx() * 0.5f * 0.5f; + + // Port items are drawn over the buildings (drawBuildings runs first) but clipped + // to a thin margin at each machine's edges: the clip region is the whole view + // minus every machine's interior (its footprint inset by kPortMarginTiles). So a + // transiting item shows only near the port edge — appearing to emerge from / sink + // into the machine (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE) and staying + // visible in the ~2×margin band at a seam between two touching buildings + // (REQ-MAT-DIRECT-COUPLE). Transport tiles are not machines and never occlude, so + // items on belts stay fully visible. + constexpr double kPortMarginTiles = 0.2; + const double margin = kPortMarginTiles * static_cast(coordinates.getTilePx()); + + QRegion clip(painter.viewport()); + for (const Building& b : getAllBuildings(m_sim.getFactoryState())) + { + if (b.type == BuildingType::Belt || b.type == BuildingType::Splitter + || b.type == BuildingType::TunnelEntry || b.type == BuildingType::TunnelExit) + { + continue; + } + const std::set cells(b.bodyCells.begin(), b.bodyCells.end()); + for (const QPoint& cell : b.bodyCells) + { + // Inset an edge only where the neighbouring cell is not part of the same + // building, so interior cell seams stay filled (handles L-shaped footprints). + const double l = cells.count(cell + QPoint(-1, 0)) ? 0.0 : margin; + const double t = cells.count(cell + QPoint( 0, -1)) ? 0.0 : margin; + const double r = cells.count(cell + QPoint( 1, 0)) ? 0.0 : margin; + const double d = cells.count(cell + QPoint( 0, 1)) ? 0.0 : margin; + clip = clip.subtracted( + QRegion(coordinates.tileRect(cell).adjusted(l, t, -r, -d).toRect())); + } + } + + // 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 QPointF center = coordinates.worldToWidget( + QVector2D(static_cast(worldPos.x()), + static_cast(worldPos.y()))); + drawWorldItem(painter, type.id, center, halfPx); + }; + + painter.save(); + painter.setClipRegion(clip); + m_sim.getBuildings().forEachEmergingItem(m_sim.getFactoryState(), drawItem); + m_sim.getBuildings().forEachIncomingItem(m_sim.getFactoryState(), drawItem); + painter.restore(); +} + +void WorldRenderer::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 WorldRenderer::drawBeltItems(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& /*frame*/) +{ + const float halfPx = coordinates.getTilePx() * 0.5f * 0.5f; + const QRect vr = coordinates.getViewportRect(); + + m_sim.getBelts().forEachVisualItem(vr, [&](const VisualItem& vi) + { + const QPointF center = coordinates.worldToWidget( + QVector2D(static_cast(vi.worldPos.x()), + static_cast(vi.worldPos.y()))); + drawWorldItem(painter, vi.type.id, center, halfPx); + }); +} + +void WorldRenderer::drawDebris(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& /*frame*/) +{ + for (const DebrisInfo& debris : getAllDebrisInfo(m_sim.getAdmin())) + { + drawDebrisMarker(painter, coordinates, + coordinates.worldToWidget(debris.position)); + } +} + +void WorldRenderer::drawStations(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame) +{ + m_sim.getAdmin().forEach( + [&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, + const HealthComponent& h) + { + const BuildingType visType = f.isEnemy + ? BuildingType::EnemyDefenceStation + : BuildingType::PlayerDefenceStation; + const std::map::const_iterator it = + m_visuals.buildings.find(visType); + if (it == m_visuals.buildings.end()) { return; } + const BuildingVisuals& bv = it->second; + + painter.setPen(Qt::NoPen); + for (const QPoint& cell : sb.bodyCells) + { + painter.fillRect(coordinates.tileRect(cell), bv.fill); + } + + const QPointF tl = + coordinates.tileToWidget(QPoint(sb.anchor.x(), sb.anchor.y())); + const QRectF bboxRect(tl.x(), tl.y(), + sb.footprint.width() * static_cast(coordinates.getTilePx()), + sb.footprint.height() * static_cast(coordinates.getTilePx())); + + painter.setPen(QPen(bv.outline, 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(bboxRect); + + // Station icon over the fill (REQ-UI-WORLD-ICON); the same symbol is + // colored blue/red by the player/enemy fill it is drawn over. + drawBuildingIcon(painter, coordinates, visType, bboxRect, bv.fill); + + if (frame.selection.isActorSelected(e)) + { + painter.setPen(QPen(m_visuals.overlays.selectedOutline, 2)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(bboxRect.adjusted(-2, -2, 2, 2)); + } + + // HP bar below footprint. + if (h.maxHp > 0.0f) + { + drawHealthBar(painter, coordinates, + bboxRect.left(), bboxRect.bottom() + 1.0, + bboxRect.width(), h.hp / h.maxHp, f.isEnemy); + } + }); +} + +void WorldRenderer::drawShips(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame) +{ + const float forward = getShipForwardExtentPx(coordinates); + + m_sim.getAdmin().forEach( + [&](entt::entity e, const ShipIdentityComponent& si, + const PositionComponent& pos, const FacingComponent& facing, + const FactionComponent& fac, const HealthComponent& h) + { + const std::map::const_iterator it = + m_visuals.ships.find(si.schematicId); + if (it == m_visuals.ships.end()) { return; } + + const QPointF center = coordinates.worldToWidget(pos.value); + drawShipBody(painter, coordinates, center, facing.radians, + it->second.fill, it->second.outline); + + if (frame.selection.isActorSelected(e)) + { + painter.setPen(QPen(m_visuals.overlays.selectedOutline, 2)); + painter.setBrush(Qt::NoBrush); + const qreal r = static_cast(forward) + 2.0; + painter.drawEllipse(center, r, r); + } + + if (h.maxHp > 0.0f) + { + const qreal barW = static_cast(forward) * 2.0; + const qreal barX = center.x() - static_cast(forward); + const qreal barY = center.y() + static_cast(forward) + 1.0; + drawHealthBar(painter, coordinates, barX, barY, barW, + h.hp / h.maxHp, fac.isEnemy); + } + }); +} + +void WorldRenderer::drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& /*frame*/) +{ + m_sim.getAdmin().forEach( + [&](entt::entity /*e*/, const ShipIdentityComponent& si, + const PositionComponent& pos, const FacingComponent& /*facing*/, + const FactionComponent& /*fac*/, const SensorRangeComponent& sensor) + { + const std::map::const_iterator it = + m_visuals.ships.find(si.schematicId); + if (it == m_visuals.ships.end()) { return; } + + drawSensorRange(painter, coordinates, + coordinates.worldToWidget(pos.value), + sensor.value_tiles, it->second.outline); + }); +} + +void WorldRenderer::drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& /*frame*/) +{ + // Draw a thin translucent line from a ship to a target, colored by the ship's + // own schematic fill. Shared by the attack, repair and salvage target lines. + const std::function + drawTargetLine = [&](const std::string& schematicId, const QVector2D& from, + const QVector2D& to) + { + const std::map::const_iterator it = + m_visuals.ships.find(schematicId); + if (it == m_visuals.ships.end()) { return; } + + QColor lineColor = it->second.fill; + lineColor.setAlpha(128); + painter.setPen(QPen(lineColor, 1)); + painter.drawLine(coordinates.worldToWidget(from), + coordinates.worldToWidget(to)); + }; + + m_sim.getAdmin().forEach( + [&](entt::entity /*e*/, const ShipIdentityComponent& si, + const PositionComponent& pos, const AttackBehavior& attack) + { + if (!attack.currentTarget.has_value()) { return; } + + const std::optional targetPos = + entityPosition(*attack.currentTarget); + if (!targetPos.has_value()) { return; } + + drawTargetLine(si.schematicId, pos.value, *targetPos); + }); + + m_sim.getAdmin().forEach( + [&](entt::entity /*e*/, const ShipIdentityComponent& si, + const PositionComponent& pos, const RepairBehavior& repair) + { + if (!repair.currentTarget.has_value()) { return; } + + const std::optional targetPos = + entityPosition(*repair.currentTarget); + if (!targetPos.has_value()) { return; } + + drawTargetLine(si.schematicId, pos.value, *targetPos); + }); + + m_sim.getAdmin().forEach( + [&](entt::entity /*e*/, const ShipIdentityComponent& si, + const PositionComponent& pos, const SalvageScrapBehavior& salvage) + { + if (!salvage.debrisTarget.has_value()) { return; } + + drawTargetLine(si.schematicId, pos.value, *salvage.debrisTarget); + }); +} + +void WorldRenderer::drawDebugOverlay(QPainter& painter) +{ + painter.resetTransform(); + + const QStringList lines = { + tr("Accumulated Threat Level: %1") + .arg(m_sim.getThreatLevel(), 0, 'f', 1), + tr("Time until Wave: %1s") + .arg(ticksToSeconds(m_sim.getNormalGapRemainingTicks()), 0, 'f', 1), + tr("Threat Accumulation Rate: %1 threat/s") + .arg(m_sim.getThreatAccumulationRate(), 0, 'f', 1), + tr("Max Factory Production: %1 threat/s") + .arg(m_sim.getMaxFactoryProductionThreatRate(), 0, 'f', 1), + tr("Current Factory Production: %1 threat/s") + .arg(m_sim.getCurrentFactoryProductionThreatRate(), 0, 'f', 1), + }; + + QFont font = painter.font(); + font.setPointSize(m_visuals.toast.fontSize); + painter.setFont(font); + + const QFontMetrics fm = painter.fontMetrics(); + const int lineH = fm.height(); + const int padding = 8; + const int spacing = 4; + + int textW = 0; + for (const QString& line : lines) + { + textW = std::max(textW, fm.horizontalAdvance(line)); + } + const int bgW = textW + padding * 2; + const int bgH = lineH * lines.size() + spacing * (lines.size() - 1) + padding * 2; + + const QRect bgRect(padding, padding, bgW, bgH); + painter.fillRect(bgRect, QColor(0, 0, 0, 160)); + + painter.setPen(Qt::white); + int y = padding * 2; + for (const QString& line : lines) + { + const QRect textRect(padding * 2, y, textW, lineH); + painter.drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, line); + y += lineH + spacing; + } +} + +void WorldRenderer::drawBeams(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame) +{ + const QPainter::RenderHints savedHints = painter.renderHints(); + painter.setRenderHint(QPainter::Antialiasing, true); + + for (const ActiveBeam& beam : frame.beams) + { + const std::optional shooterPos = entityPosition(beam.event.shooter); + const std::optional targetPos = entityPosition(beam.event.target); + if (!shooterPos.has_value() || !targetPos.has_value()) { continue; } + + QColor color = m_visuals.beams.weaponColor; + switch (beam.event.kind) + { + case BeamKind::Weapon: color = m_visuals.beams.weaponColor; break; + case BeamKind::Repair: color = m_visuals.beams.repairColor; break; + case BeamKind::Salvage: color = m_visuals.beams.salvageColor; break; + } + + const QPointF s = coordinates.worldToWidget(*shooterPos); + const QPointF t = coordinates.worldToWidget(*targetPos + beam.targetOffset); + + // Unit direction/perpendicular of the beam in widget space. A degenerate + // zero-length beam (shooter and target coincide) has no direction to + // taper along, so skip it. + const QVector2D delta(static_cast(t.x() - s.x()), + static_cast(t.y() - s.y())); + const float lengthPx = delta.length(); + if (lengthPx < 0.001f) { continue; } + const QVector2D dir = delta / lengthPx; + const QVector2D perp(-dir.y(), dir.x()); + + // Directional taper: draw the beam as a quad that is wide at the shooter + // and narrows to a faint tip at the target, so it reads as an arrow + // pointing away from whoever fired it. Without this, a beam strung + // between two nearby ships is symmetric and gives no cue which end is the + // source (the readability problem this addresses). + const float widthPx = std::max(1.0f, static_cast(m_visuals.beams.widthPx)); + const float baseHalf = widthPx * 1.f; + const float tipHalf = widthPx * 0.35f; + + QPolygonF quad; + quad << QPointF(s.x() + static_cast(perp.x() * baseHalf), + s.y() + static_cast(perp.y() * baseHalf)) + << QPointF(s.x() - static_cast(perp.x() * baseHalf), + s.y() - static_cast(perp.y() * baseHalf)) + << QPointF(t.x() - static_cast(perp.x() * tipHalf), + t.y() - static_cast(perp.y() * tipHalf)) + << QPointF(t.x() + static_cast(perp.x() * tipHalf), + t.y() + static_cast(perp.y() * tipHalf)); + + QColor bright = color; + bright.setAlpha(255); + QColor faint = color; + faint.setAlpha(90); + + QLinearGradient bodyGrad(s, t); + bodyGrad.setColorAt(0.0, bright); + bodyGrad.setColorAt(1.0, faint); + + painter.setPen(Qt::NoPen); + painter.setBrush(bodyGrad); + painter.drawPolygon(quad); + } + + painter.setBrush(Qt::NoBrush); + painter.setRenderHints(savedHints); +} + +void WorldRenderer::drawSelectedTunnelConnections(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame) +{ + if (frame.selection.getSelectedBuildings().empty()) { return; } + + const TunnelTileMap tunnels = collectTunnelTiles(m_sim.getFactoryState()); + if (tunnels.empty()) { return; } + + const TunnelLookup lookup = makeTunnelLookup(tunnels); + + // Collect the tiles to highlight in a set so a connection selected from both ends + // (or overlapping runs) is filled exactly once — filling a semi-transparent green + // twice would darken it (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). + std::set highlightTiles; + for (const BuildingId id : frame.selection.getSelectedBuildings()) + { + std::optional anchor; + std::optional type; + Rotation rotation = Rotation::East; + if (const Building* b = findBuilding(m_sim.getFactoryState(), id)) + { + anchor = b->anchor; type = b->type; rotation = b->rotation; + } + else if (const ConstructionSite* s = findSite(m_sim.getFactoryState(), id)) + { + anchor = s->anchor; type = s->type; rotation = s->rotation; + } + if (!type.has_value() + || (*type != BuildingType::TunnelEntry && *type != BuildingType::TunnelExit)) + { + continue; + } + + const std::optional partner = findTunnelPartner( + lookup, *anchor, *type, rotation, m_sim.getConfig().world.tunnelMaxDistance_tiles); + if (!partner.has_value()) { continue; } + + // Add every tile from the selected end to its partner inclusive (colinear run). + const QPoint delta = *partner - *anchor; + const QPoint stepDir((delta.x() > 0) - (delta.x() < 0), + (delta.y() > 0) - (delta.y() < 0)); + for (QPoint t = *anchor; ; t += stepDir) + { + highlightTiles.insert(t); + if (t == *partner) { break; } + } + } + + const QColor green = m_visuals.overlays.tunnelPreview; + for (const QPoint& tile : highlightTiles) + { + painter.fillRect(coordinates.tileRect(tile), green); + } +} + +void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame) +{ + // Green connection highlight for any selected tunnel end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). + drawSelectedTunnelConnections(painter, coordinates, frame); + + // Builder-mode ghost + if (frame.buildMode.isBuilderMode()) + { + if (frame.buildMode.getBuilderType() == BuildingType::Belt + && frame.buildMode.isDraggingBelt()) + { + // Belt drag: a ghost per path tile (REQ-BLD-BELT-DRAG). Rotate-in-place + // and affordable new tiles use the belt colors; occupied/invalid tiles + // use the invalid color; unaffordable tiles show no ghost at all. + const std::vector resolved = resolveBeltDragPath(frame.buildMode.getBeltDragPath(), m_sim.getFactoryState(), m_sim.getConfig(), m_sim.getBuildingBlocksStock()); + for (std::size_t index = 0; index < resolved.size(); ++index) + { + const BeltDragResolved& item = resolved[index]; + if (item.action == BeltTileAction::PlaceNew && !item.affordable) + { + continue; + } + const BeltPathTile& entry = frame.buildMode.getBeltDragPath()[index]; + drawBuildingGhost(painter, coordinates, BuildingType::Belt, + entry.tile, entry.rotation, + /*valid*/ item.action != BeltTileAction::Invalid, + /*showPortTargetGlyphs*/ true); + } + } + else + { + // In tunnel mode the ghost shows the position-resolved type (entry or + // exit) and, when it would complete an existing tunnel, the matched end + // and the tiles between it and the ghost are tinted green + // (REQ-BLD-TUNNEL-MODE). + const QPoint ghostTile = frame.buildMode.getGhostTile(); + const std::optional& partnerTile = frame.buildMode.getTunnelPartnerTile(); + if (frame.buildMode.isTunnelMode() && frame.buildMode.isGhostValid() + && partnerTile.has_value()) + { + const QColor green = m_visuals.overlays.tunnelPreview; + painter.fillRect(coordinates.tileRect(*partnerTile), green); + + // Partner and ghost tile are colinear along the tunnel run; tint the + // tiles strictly between them. + const QPoint delta = *partnerTile - ghostTile; + const QPoint step((delta.x() > 0) - (delta.x() < 0), + (delta.y() > 0) - (delta.y() < 0)); + for (QPoint t = ghostTile + step; t != *partnerTile; t += step) + { + painter.fillRect(coordinates.tileRect(t), green); + } + } + + drawBuildingGhost(painter, coordinates, + frame.buildMode.getEffectiveBuilderType(), + ghostTile, frame.buildMode.getGhostRotation(), + frame.buildMode.isGhostValid(), + /*showPortTargetGlyphs*/ true); + } + } + + // Blueprint placement ghost + if (frame.buildMode.isBlueprintMode()) + { + for (const BlueprintBuilding& bb : frame.buildMode.getBlueprint().buildings) + { + // Locked building types are omitted from the blueprint (REQ-LOCK-BUILDING, + // REQ-LOCK-UI-BLUEPRINT), so they are not ghosted either. + if (!m_sim.isBuildingUnlocked(bb.type)) { continue; } + const QPoint anchor = frame.buildMode.getBlueprintGhostTile() + bb.offset; + const bool valid = canPlaceBuilding(m_sim.getFactoryState(), m_sim.getConfig(), bb.type, anchor, bb.rotation); + drawBuildingGhost(painter, coordinates, bb.type, anchor, bb.rotation, + valid, /*showPortTargetGlyphs*/ false); + } + } + + // Queued for deconstruction: tint every building currently in the + // deconstruction queue, regardless of mode (REQ-BLD-DECON-QUEUE). + for (const Building& b : getAllBuildings(m_sim.getFactoryState())) + { + if (!b.queuedForDeconstruction) { continue; } + for (const QPoint& cell : b.bodyCells) + { + painter.fillRect(coordinates.tileRect(cell), + m_visuals.overlays.deconstructTint); + } + } + + // Deconstruct tint: while dragging a deconstruct box, tint every covered + // building/site (REQ-BLD-DECONSTRUCT-BOX); otherwise tint the hovered one. + if (frame.buildMode.isDeconstructMode() && frame.isBoxSelecting) + { + for (BuildingId id : buildingsInBox(m_sim.getFactoryState(), frame.boxStartTile, frame.boxCurrentTile)) + { + const Building* b = findBuilding(m_sim.getFactoryState(), id); + if (b && b->type == BuildingType::Hq) { continue; } + const std::vector* cells = nullptr; + const ConstructionSite* s = nullptr; + if (b) { cells = &b->bodyCells; } + else if ((s = findSite(m_sim.getFactoryState(), id))) { cells = &s->bodyCells; } + if (cells) + { + for (const QPoint& cell : *cells) + { + painter.fillRect(coordinates.tileRect(cell), + m_visuals.overlays.deconstructTint); + } + } + } + } + else if (frame.buildMode.isDeconstructMode() + && frame.buildMode.getDeconstructHoverBuildingId().has_value()) + { + const Building* b = findBuilding(m_sim.getFactoryState(), + *frame.buildMode.getDeconstructHoverBuildingId()); + if (b) + { + for (const QPoint& cell : b->bodyCells) + { + painter.fillRect(coordinates.tileRect(cell), + m_visuals.overlays.deconstructTint); + } + } + } + + // Box-select rectangle + if (frame.isBoxSelecting) + { + const QPoint tl(std::min(frame.boxStartTile.x(), frame.boxCurrentTile.x()), + std::min(frame.boxStartTile.y(), frame.boxCurrentTile.y())); + const QPoint br(std::max(frame.boxStartTile.x(), frame.boxCurrentTile.x()) + 1, + std::max(frame.boxStartTile.y(), frame.boxCurrentTile.y()) + 1); + const QRectF selRect(coordinates.tileToWidget(tl), + coordinates.tileToWidget(br)); + painter.setPen(QPen(m_visuals.overlays.selectionRect, 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(selRect); + } +} + +void WorldRenderer::drawBuildingGhost(QPainter& painter, + const WorldCoordinates& coordinates, + BuildingType type, + QPoint anchorTile, Rotation rotation, + bool valid, bool showPortTargetGlyphs) +{ + const BuildingDef* def = m_sim.getConfig().buildings.findBuildingDef(type); + if (!def) { return; } + + const std::map::const_iterator it = + m_visuals.buildings.find(type); + if (it == m_visuals.buildings.end()) { return; } + const BuildingVisuals& bv = it->second; + + // Valid ghosts show the building type's own colors; invalid ghosts override + // with the distinct invalid color (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID). The + // invalid color's RGB is taken at full opacity so it does not double-dim + // against the setOpacity below (the configured color carries its own alpha). + const QColor invalidColor(m_visuals.overlays.ghostInvalid.red(), + m_visuals.overlays.ghostInvalid.green(), + m_visuals.overlays.ghostInvalid.blue()); + const QColor fillColor = valid ? bv.fill : invalidColor; + const QColor lineColor = valid ? bv.outline : invalidColor; + + const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation); + if (parsed.bodyCells.empty()) { return; } + + painter.setOpacity(0.5); + + QPoint minCell = parsed.bodyCells.front(); + QPoint maxCell = parsed.bodyCells.front(); + for (const QPoint& cell : parsed.bodyCells) + { + painter.fillRect(coordinates.tileRect(anchorTile + cell), fillColor); + minCell.setX(std::min(minCell.x(), cell.x())); + minCell.setY(std::min(minCell.y(), cell.y())); + maxCell.setX(std::max(maxCell.x(), cell.x())); + maxCell.setY(std::max(maxCell.y(), cell.y())); + } + + const QPointF tl = coordinates.tileToWidget(anchorTile + minCell); + const QRectF bboxRect(tl.x(), tl.y(), + (maxCell.x() - minCell.x() + 1) * static_cast(coordinates.getTilePx()), + (maxCell.y() - minCell.y() + 1) * static_cast(coordinates.getTilePx())); + + painter.setPen(QPen(lineColor, 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(bboxRect); + + // Icon glyph over the ghost fill (REQ-UI-WORLD-ICON); an invalid ghost's fill + // is the red invalid color, which auto-contrasts to a white icon. + if (!drawBuildingIcon(painter, coordinates, type, bboxRect, fillColor) + && !bv.glyph.isEmpty()) + { + painter.setPen(lineColor); + painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph); + } + + for (const Port& port : parsed.outputPorts) + { + drawPortGlyph(painter, coordinates, + anchorTile + outputBodyTile(port.tile, port.direction), + port.direction, lineColor, /*centered*/ false); + } + + // REQ-UI-PORT-TARGET-GLYPH: while in builder mode, additionally mark each + // output port's target cell (the cell just outside the footprint the port + // pushes into) with a directional glyph, previewing where output will flow. + // The Tunnel Entry is excluded — it receives items from any of its non-mouth + // edges rather than emitting into a single adjacent cell. The Shipyard is + // excluded too — it spawns a ship rather than emitting a belt item. + if (showPortTargetGlyphs + && type != BuildingType::TunnelEntry + && type != BuildingType::Shipyard) + { + for (const Port& port : parsed.outputPorts) + { + drawPortGlyph(painter, coordinates, anchorTile + port.tile, + port.direction, lineColor, /*centered*/ true); + } + } + + painter.setOpacity(1.0); +} diff --git a/src/ui/WorldRenderer.h b/src/ui/WorldRenderer.h new file mode 100644 index 0000000..40e5d0d --- /dev/null +++ b/src/ui/WorldRenderer.h @@ -0,0 +1,183 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "BeamFiredEvent.h" +#include "BuildModeController.h" +#include "BuildingConfig.h" +#include "BuildingId.h" +#include "BuildingType.h" +#include "Rotation.h" +#include "SelectionController.h" +#include "VisualsConfig.h" +#include "WorldCoordinates.h" + +class ItemIconCache; +class Simulation; +class QPainter; +class QSvgRenderer; + +// A beam still being drawn. Lifetime is counted in game ticks so beams freeze with +// the simulation when it is paused or slowed (REQ-SHP-FIRING-BEAM); the view owns +// the ageing, the renderer only draws what it is given. +struct ActiveBeam +{ + BeamFiredEvent event; + QVector2D targetOffset; +}; + +// Brief outline flash shown on a building when settings are copied from it or +// pasted onto it (REQ-BLD-COPY-CONFIG-FEEDBACK). remainingMs counts down in +// wall-clock time so the flash plays at a fixed length regardless of game speed +// (and while paused). +struct CopyConfigFlash +{ + BuildingId id; + qint64 remainingMs; +}; + +// Everything the renderer draws that the simulation does not know about: what the +// player has selected, which build mode is active, and the other transient bits of +// interaction state the view owns. Assembled fresh each frame and passed by +// reference, so the renderer holds no copy that could go stale. +struct WorldRenderFrame +{ + const SelectionController& selection; + const BuildModeController& buildMode; + const std::vector& beams; + const std::optional& copiedConfig; + const std::vector& copyConfigFlashes; + bool isBoxSelecting; + QPoint boxStartTile; + QPoint boxCurrentTile; + bool isDebugDrawEnabled; +}; + +// Draws the game world: terrain, buildings, items, ships, effects and the build +// overlays, in back-to-front order (see the Layer Order section of +// docs/architecture.md). +// +// Reads the simulation and never writes it, and knows nothing about input — the +// view resolves clicks and owns the interaction state, and hands the parts the +// renderer needs over in a WorldRenderFrame. Screen-anchored chrome that is not +// part of the world (the pause and deconstruct vignettes, the replay overlay) +// stays with the view, which is also why those never took a WorldCoordinates. +class WorldRenderer +{ + // Not a QObject, but drawDebugOverlay puts text on screen, so it still needs + // tr() for translation (REQ-UI-...): this declares the tr() overloads without + // dragging in the meta-object system. + Q_DECLARE_TR_FUNCTIONS(WorldRenderer) + +public: + // `itemIcons` is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); not + // owned, must outlive this renderer. `configDir` is used once, to load the + // per-building world icons. + WorldRenderer(Simulation& sim, const VisualsConfig& visuals, + ItemIconCache* itemIcons, const std::string& configDir); + ~WorldRenderer(); + + void render(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + +private: + void drawTiles(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + void drawBuildings(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + void drawSelectionHighlights(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + void drawCopyConfigFeedback(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + void drawPortItems(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + void drawStations(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + void drawBeltItems(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + void drawDebris(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + void drawShips(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + void drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + void drawBeams(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + void drawSelectedTunnelConnections(QPainter& painter, + const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + void drawOverlays(QPainter& painter, const WorldCoordinates& coordinates, + const WorldRenderFrame& frame); + // Screen-anchored, but drawn mid-world so ships and effects still paint over + // it, as they always have. + void drawDebugOverlay(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 drawPortGlyph(QPainter& painter, const WorldCoordinates& coordinates, + QPoint tile, Rotation direction, const QColor& color, + bool centered); + void drawBuildingGhost(QPainter& painter, const WorldCoordinates& coordinates, + BuildingType type, QPoint anchorTile, Rotation rotation, + bool valid, bool showPortTargetGlyphs); + + // Loads the per-building world icons (REQ-UI-WORLD-ICON) from + // /../icons/buildings once at construction. Only the building + // types with a world icon are loaded (production buildings, HQ, stations); + // belts, splitters, and tunnels are deliberately excluded so their + // orientation stays readable. The SVG's chip background is stripped; the + // glyph is pre-rendered in both white and dark ink for auto-contrast. + void loadBuildingIcons(const std::string& configDir); + // Draws a building's world icon glyph centered in box, choosing the white or + // dark pre-rendered variant by fill luminance so it stays legible. Returns + // false if the type has no world icon (caller falls back to the text glyph). + bool drawBuildingIcon(QPainter& painter, const WorldCoordinates& coordinates, + BuildingType type, const QRectF& box, + const QColor& fill) const; + + // Widget-space rectangle covering a building or construction site's footprint, + // or nullopt if the id resolves to neither. Shared by the selection highlight + // and the copy-settings feedback (REQ-BLD-COPY-CONFIG-FEEDBACK). + std::optional footprintWidgetRect(const WorldCoordinates& coordinates, + BuildingId id) const; + + std::optional entityPosition(entt::entity entity) const; + + // Non-const only because EntityAdmin's component accessors are; the renderer + // reads the simulation and never writes it. + Simulation& m_sim; + const VisualsConfig& m_visuals; + + // Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by + // MainWindow. Shared draw path for belt and port items; pixmaps are cached + // per target size. + ItemIconCache* m_itemIcons; + + // World icon glyph renderers per building type (REQ-UI-WORLD-ICON), in a + // white and a dark variant so drawBuildingIcon can auto-contrast against the + // building's fill. Rendered as vector at the view scale each draw so they + // stay crisp. Populated once by loadBuildingIcons(). + struct BuildingIconRenderers + { + std::unique_ptr white; + std::unique_ptr dark; + }; + std::map m_buildingIcons; +};