From 9aed6d844eaf49ae819025f23a81e45777962e68 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Wed, 5 Aug 2026 19:09:40 +0200 Subject: [PATCH] extract WorldRenderer The last seam of the decomposition, and the one the earlier ones were groundwork for: every draw method already took a WorldCoordinates, read mode state through BuildModeController and selection through SelectionController, and used the shared shapes in WorldPrimitives, so the move needed almost no rewriting. paintGL is now a call sequence. The split is the world-space / screen-space line already drawn by the WorldCoordinates work: the renderer draws everything positioned in tiles, while the pause and deconstruct vignettes and the replay overlay - which never took a WorldCoordinates because they are anchored to the viewport - stay with the widget. WorldRenderFrame is what keeps the renderer independent of the widget. It reads the simulation directly, but the rest of what 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 per frame and passed by reference, so the renderer holds no copy a later click could invalidate, and it knows nothing about input. Three more queries had to stop belonging to the view first, because the renderer and the click path both need them: buildingsInBox and collectTunnelTiles move to FactoryQueries, and makeTunnelLookup with the TunnelTileMap and QPointCompare it needs move to TunnelCompletion, which already owned that concept. Two small things fell out of leaving QWidget. The port-item clip region used QWidget::rect() and now takes the painter's own viewport. drawDebugOverlay puts translated text on screen, so the renderer declares tr() via Q_DECLARE_TR_FUNCTIONS rather than becoming a QObject. drawDebugOverlay stays in the renderer despite being screen-anchored: it is drawn mid-sequence, so moving it out would put it on top of the ships instead of under them. The simulation reference is non-const only because EntityAdmin's component accessors are; the renderer never writes it. Draw order is unchanged, and so is behaviour. GameWorldView.cpp is 1431 lines, from 3265 when this branch started. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K --- docs/architecture.md | 6 +- src/lib/core/TunnelCompletion.cpp | 10 + src/lib/core/TunnelCompletion.h | 19 + src/lib/sim/FactoryQueries.cpp | 59 ++ src/lib/sim/FactoryQueries.h | 11 + src/ui/CMakeLists.txt | 2 + src/ui/GameWorldView.cpp | 1247 +---------------------------- src/ui/GameWorldView.h | 117 +-- src/ui/WorldRenderer.cpp | 1219 ++++++++++++++++++++++++++++ src/ui/WorldRenderer.h | 183 +++++ 10 files changed, 1531 insertions(+), 1342 deletions(-) create mode 100644 src/ui/WorldRenderer.cpp create mode 100644 src/ui/WorldRenderer.h 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; +};