Render building icons in the game world

Draw each building's icon glyph over its footprint fill in place of the text
identity glyph (REQ-UI-WORLD-ICON): production buildings, HQ, and defence
stations. GameWorldView pre-renders each type's icon (chip background stripped)
in a white and a dark variant and picks the contrasting one by fill luminance;
belts, splitters, and tunnels are excluded and keep their tile/text rendering.
Wired into operational buildings, construction sites (icon + progress), stations
(previously letterless), and the builder/blueprint ghost. Update the requirement
wording from "white glyph" to a contrasting ink.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc
This commit is contained in:
2026-07-22 22:36:56 +02:00
parent ef0191132b
commit 4baab16794
3 changed files with 140 additions and 9 deletions

View File

@@ -9,10 +9,12 @@
#include <memory>
#include <string>
#include <QByteArray>
#include <QColor>
#include <QCoreApplication>
#include <QCursor>
#include <QDir>
#include <QFile>
#include <QFont>
#include <QKeyEvent>
#include <QLinearGradient>
@@ -21,10 +23,13 @@
#include <QPainter>
#include <QPainterPath>
#include <QPen>
#include <QPixmap>
#include <QPolygonF>
#include <QRadialGradient>
#include <QRegion>
#include <QRegularExpression>
#include <QStringList>
#include <QSvgRenderer>
#include <QTimer>
#include "AttackBehavior.h"
@@ -77,6 +82,54 @@
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" },
};
// Renders an icon SVG's glyph only (the chip background rect stripped) into a
// transparent pixmap, recoloring the white glyph stroke to inkHex.
QPixmap renderWorldIcon(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);
const int px = 192;
QPixmap pixmap(px, px);
pixmap.fill(Qt::transparent);
QSvgRenderer renderer(s.toUtf8());
QPainter painter(&pixmap);
renderer.render(&painter);
return pixmap;
}
// Perceived luminance test; picks a dark glyph on light fills so it stays legible.
bool isLightFill(const QColor& c)
{
const double lum = (0.299 * c.red() + 0.587 * c.green() + 0.114 * c.blue()) / 255.0;
return lum > 0.6;
}
// Keep only the filter entries whose item type is currently unlocked
// (REQ-LOCK-UI-BLUEPRINT). An empty result means "accept all".
std::vector<ItemType> filterUnlockedItems(const std::vector<ItemType>& filter,
@@ -169,6 +222,8 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true);
loadBuildingIcons(configDir);
m_renderTimer = new QTimer(this);
m_renderTimer->setInterval(16);
connect(m_renderTimer, &QTimer::timeout, this, &GameWorldView::onFrame);
@@ -1209,6 +1264,47 @@ void GameWorldView::drawTiles(QPainter& painter)
}
}
void GameWorldView::loadBuildingIcons(const std::string& configDir)
{
// Icons live beside the config dir, read at runtime like visuals.toml
// (REQ-UI-WORLD-ICON), mirroring how MainWindow derives the same path.
const QString iconDir = QDir::cleanPath(
QString::fromStdString(configDir) + "/../icons/buildings");
for (const WorldIconEntry& entry : kWorldIconFiles)
{
QFile file(iconDir + "/" + QString::fromLatin1(entry.file));
if (!file.open(QIODevice::ReadOnly))
{
continue; // A missing icon is not an error; the text glyph is used.
}
const QByteArray svg = file.readAll();
BuildingIconPixmaps pixmaps;
pixmaps.white = renderWorldIcon(svg, QStringLiteral("#ffffff"));
pixmaps.dark = renderWorldIcon(svg, QStringLiteral("#1c1c20"));
m_buildingIcons[entry.type] = pixmaps;
}
}
bool GameWorldView::drawBuildingIcon(QPainter& painter, BuildingType type,
const QRectF& box, const QColor& fill) const
{
const std::map<BuildingType, BuildingIconPixmaps>::const_iterator it =
m_buildingIcons.find(type);
if (it == m_buildingIcons.end()) { return false; }
// Centered square keeps the glyph undistorted on non-square footprints; the
// icon's own 13% margin supplies padding.
const qreal side = std::min(box.width(), box.height());
const QRectF target(box.center().x() - side / 2.0,
box.center().y() - side / 2.0, side, side);
const QPixmap& pixmap = isLightFill(fill) ? it->second.dark : it->second.white;
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.drawPixmap(target, pixmap, pixmap.rect());
return true;
}
void GameWorldView::drawBuildings(QPainter& painter)
{
for (const Building& b : m_sim->getBuildings().getAllBuildings())
@@ -1233,7 +1329,9 @@ void GameWorldView::drawBuildings(QPainter& painter)
painter.setBrush(Qt::NoBrush);
painter.drawRect(bboxRect);
if (!bv.glyph.isEmpty())
// 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, b.type, bboxRect, bv.fill) && !bv.glyph.isEmpty())
{
painter.setPen(bv.outline);
painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph);
@@ -1328,13 +1426,19 @@ void GameWorldView::drawBuildings(QPainter& painter)
const QString pctText = QString::number(pct) + "%";
painter.setPen(bv.outline);
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);
// Identity symbol above, progress percentage below (REQ-UI-CONSTRUCTION-PROGRESS):
// the world icon where the type has one, otherwise the text glyph.
if (drawBuildingIcon(painter, s.type, topHalf, bv.fill))
{
painter.drawText(botHalf, Qt::AlignCenter, 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);
}
@@ -1578,6 +1682,10 @@ void GameWorldView::drawStations(QPainter& painter)
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, visType, bboxRect, bv.fill);
if (isEntitySelected(e))
{
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
@@ -2082,7 +2190,9 @@ void GameWorldView::drawBuildingGhost(QPainter& painter, BuildingType type,
painter.setBrush(Qt::NoBrush);
painter.drawRect(bboxRect);
if (!bv.glyph.isEmpty())
// 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, type, bboxRect, fillColor) && !bv.glyph.isEmpty())
{
painter.setPen(lineColor);
painter.drawText(bboxRect, Qt::AlignCenter, bv.glyph);

View File

@@ -8,8 +8,10 @@
#include <utility>
#include <vector>
#include <QColor>
#include <QElapsedTimer>
#include <QOpenGLWidget>
#include <QPixmap>
#include <QPoint>
#include <QRectF>
#include <QTimer>
@@ -187,6 +189,19 @@ private:
QPoint anchorTile, Rotation rotation, bool valid,
bool showPortTargetGlyphs);
// Loads the per-building world icons (REQ-UI-WORLD-ICON) from
// <configDir>/../icons/buildings once at construction. Only the building
// types with a world icon are loaded (production buildings, HQ, stations);
// belts, splitters, and tunnels are deliberately excluded so their
// orientation stays readable. The SVG's chip background is stripped; the
// glyph is pre-rendered in both white and dark ink for auto-contrast.
void loadBuildingIcons(const std::string& configDir);
// Draws a building's world icon glyph centered in box, choosing the white or
// dark pre-rendered variant by fill luminance so it stays legible. Returns
// false if the type has no world icon (caller falls back to the text glyph).
bool drawBuildingIcon(QPainter& painter, BuildingType type,
const QRectF& box, const QColor& fill) const;
void placeBlueprintAtTile(QPoint center);
std::optional<QVector2D> entityPosition(entt::entity entity) const;
@@ -269,6 +284,12 @@ private:
const GameConfig* m_config;
const VisualsConfig* m_visuals;
// Pre-rendered world icon glyphs per building type (REQ-UI-WORLD-ICON), in a
// white and a dark variant so drawBuildingIcon can auto-contrast against the
// building's fill. Populated once by loadBuildingIcons().
struct BuildingIconPixmaps { QPixmap white; QPixmap dark; };
std::map<BuildingType, BuildingIconPixmaps> m_buildingIcons;
// Funnels all player input into the single Simulation::apply chokepoint.
CommandManager m_commandManager;
// A Reset command was enqueued; reset the view after the next drain applies it.