#include "WorldRenderer.h" #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); drawStations(painter, coordinates, frame); drawBeltItems(painter, coordinates, frame); drawDebris(painter, coordinates, frame); if (frame.isDebugDrawEnabled) { drawDebugSensorRanges(painter, coordinates, frame); drawDebugTargetLines(painter, coordinates, frame); } 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::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::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, item.action != BeltTileAction::Invalid ? GhostTint::Normal : GhostTint::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() ? GhostTint::Normal : GhostTint::Invalid, /*showPortTargetGlyphs*/ true); } } // Blueprint placement ghost if (frame.buildMode.isBlueprintMode()) { // A single-building blueprint hit-tests the cursor for its transfer target; a // constellation does not (REQ-UI-BLUEPRINT-TRANSFER). The stored building count, // not the count after locked types are dropped, so the rule does not shift as the // player unlocks things. const QPoint cursorTile = frame.buildMode.getBlueprintGhostTile(); const std::optional hoverTile = frame.buildMode.getBlueprint().buildings.size() == 1 ? std::make_optional(cursorTile) : std::nullopt; 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; } // The same classifier the click path uses, so the color always predicts what // clicking would do (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). A // compatible overlap is an ordinary valid ghost. The resolved anchor and // rotation are drawn rather than the blueprint's own, so a hovered transfer // target shows the ghost snapped onto it. const BlueprintGhostResolved resolved = resolveBlueprintGhost( m_sim.getFactoryState(), m_sim.getConfig(), bb.type, cursorTile + bb.offset, bb.rotation, hoverTile); GhostTint tint = GhostTint::Normal; if (resolved.action == BlueprintGhostAction::Transfer) { tint = GhostTint::Transfer; } else if (resolved.action == BlueprintGhostAction::Invalid) { tint = GhostTint::Invalid; } drawBuildingGhost(painter, coordinates, bb.type, resolved.ghostAnchor, resolved.ghostRotation, tint, /*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, GhostTint tint, 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; // Normal ghosts show the building type's own colors; the other two tints override // them with a single flat color -- invalid (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID) or // configuration transfer (REQ-UI-BLUEPRINT-TRANSFER). An override'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& configured = tint == GhostTint::Transfer ? m_visuals.overlays.configTransfer : m_visuals.overlays.ghostInvalid; const QColor overrideColor(configured.red(), configured.green(), configured.blue()); const bool useOwnColors = tint == GhostTint::Normal; const QColor fillColor = useOwnColors ? bv.fill : overrideColor; const QColor lineColor = useOwnColors ? bv.outline : overrideColor; 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); }