Files
dota_factory/src/ui/WorldRenderer.cpp

1160 lines
50 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "WorldRenderer.h"
#include <algorithm>
#include <cmath>
#include <functional>
#include <set>
#include <QByteArray>
#include <QDir>
#include <QFile>
#include <QFont>
#include <QLinearGradient>
#include <QPainter>
#include <QPen>
#include <QPolygonF>
#include <QRegion>
#include <QRegularExpression>
#include <QSvgRenderer>
#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 <rect>
// elements (e.g. the miner's drill housing, the hq's base), so match the
// 100x100 background rect specifically.
static const QRegularExpression backgroundRect(
QStringLiteral("<rect\\s+width=\"100\"\\s+height=\"100\"[^>]*>"));
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<QVector2D> WorldRenderer::entityPosition(entt::entity entity) const
{
if (!m_sim.getAdmin().isValid(entity) || !m_sim.getAdmin().hasAll<PositionComponent>(entity))
{
return std::nullopt;
}
return m_sim.getAdmin().get<PositionComponent>(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<qreal>(px) * 0.5,
tr.y() + static_cast<qreal>(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<qreal>(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<int>(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<int>(std::floor(coordinates.getViewLeftTiles())) - 1;
const int rightTile = leftTile
+ static_cast<int>(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<QSvgRenderer>(
worldIconSvg(svg, QStringLiteral("#ffffff")));
renderers.dark = std::make_unique<QSvgRenderer>(
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<BuildingType, BuildingIconRenderers>::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<qreal>(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<BuildingType, BuildingVisuals>::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<qreal>(coordinates.getTilePx()),
b.footprint.height() * static_cast<qreal>(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<ProductionStatus> 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<BuildingType, BuildingVisuals>::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<qreal>(coordinates.getTilePx()),
s.footprint.height() * static_cast<qreal>(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<int>(
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<qreal>(coordinates.getTilePx()),
b.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
m_sim.getAdmin().forEach<HqProxyComponent, FactionComponent, HealthComponent>(
[&](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<QRectF> WorldRenderer::footprintWidgetRect(
const WorldCoordinates& coordinates, BuildingId id) const
{
std::optional<QPoint> anchor;
std::optional<QSize> 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<qreal>(coordinates.getTilePx()),
footprint->height() * static_cast<qreal>(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<QRectF> 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<qreal>(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<double>(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<QPoint, QPointCompare> 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<void(const ItemType&, QPointF)> drawItem =
[&](const ItemType& type, QPointF worldPos)
{
const QPointF center = coordinates.worldToWidget(
QVector2D(static_cast<float>(worldPos.x()),
static_cast<float>(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<qreal>(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<std::string, ItemVisuals>::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<float>(vi.worldPos.x()),
static_cast<float>(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<StationBodyComponent, FactionComponent, HealthComponent>(
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f,
const HealthComponent& h)
{
const BuildingType visType = f.isEnemy
? BuildingType::EnemyDefenceStation
: BuildingType::PlayerDefenceStation;
const std::map<BuildingType, BuildingVisuals>::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<qreal>(coordinates.getTilePx()),
sb.footprint.height() * static_cast<qreal>(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<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent, HealthComponent>(
[&](entt::entity e, const ShipIdentityComponent& si,
const PositionComponent& pos, const FacingComponent& facing,
const FactionComponent& fac, const HealthComponent& h)
{
const std::map<std::string, ShipVisuals>::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<qreal>(forward) + 2.0;
painter.drawEllipse(center, r, r);
}
if (h.maxHp > 0.0f)
{
const qreal barW = static_cast<qreal>(forward) * 2.0;
const qreal barX = center.x() - static_cast<qreal>(forward);
const qreal barY = center.y() + static_cast<qreal>(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<ShipIdentityComponent, PositionComponent, FacingComponent,
FactionComponent, SensorRangeComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const FacingComponent& /*facing*/,
const FactionComponent& /*fac*/, const SensorRangeComponent& sensor)
{
const std::map<std::string, ShipVisuals>::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<void(const std::string&, const QVector2D&, const QVector2D&)>
drawTargetLine = [&](const std::string& schematicId, const QVector2D& from,
const QVector2D& to)
{
const std::map<std::string, ShipVisuals>::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<ShipIdentityComponent, PositionComponent, AttackBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const AttackBehavior& attack)
{
if (!attack.currentTarget.has_value()) { return; }
const std::optional<QVector2D> targetPos =
entityPosition(*attack.currentTarget);
if (!targetPos.has_value()) { return; }
drawTargetLine(si.schematicId, pos.value, *targetPos);
});
m_sim.getAdmin().forEach<ShipIdentityComponent, PositionComponent, RepairBehavior>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
const PositionComponent& pos, const RepairBehavior& repair)
{
if (!repair.currentTarget.has_value()) { return; }
const std::optional<QVector2D> targetPos =
entityPosition(*repair.currentTarget);
if (!targetPos.has_value()) { return; }
drawTargetLine(si.schematicId, pos.value, *targetPos);
});
m_sim.getAdmin().forEach<ShipIdentityComponent, PositionComponent, SalvageScrapBehavior>(
[&](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<QVector2D> shooterPos = entityPosition(beam.event.shooter);
const std::optional<QVector2D> 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<float>(t.x() - s.x()),
static_cast<float>(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<float>(m_visuals.beams.widthPx));
const float baseHalf = widthPx * 1.f;
const float tipHalf = widthPx * 0.35f;
QPolygonF quad;
quad << QPointF(s.x() + static_cast<qreal>(perp.x() * baseHalf),
s.y() + static_cast<qreal>(perp.y() * baseHalf))
<< QPointF(s.x() - static_cast<qreal>(perp.x() * baseHalf),
s.y() - static_cast<qreal>(perp.y() * baseHalf))
<< QPointF(t.x() - static_cast<qreal>(perp.x() * tipHalf),
t.y() - static_cast<qreal>(perp.y() * tipHalf))
<< QPointF(t.x() + static_cast<qreal>(perp.x() * tipHalf),
t.y() + static_cast<qreal>(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<QPoint, QPointCompare> highlightTiles;
for (const BuildingId id : frame.selection.getSelectedBuildings())
{
std::optional<QPoint> anchor;
std::optional<BuildingType> 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<QPoint> 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<BeltDragResolved> 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<QPoint>& 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<QPoint> 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<QPoint>* 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<BuildingType, BuildingVisuals>::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<qreal>(coordinates.getTilePx()),
(maxCell.y() - minCell.y() + 1) * static_cast<qreal>(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);
}