1160 lines
50 KiB
C++
1160 lines
50 KiB
C++
#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);
|
||
}
|