Files
Ascent-of-Man/qscenerenderer.cpp
T

2729 lines
122 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 "qscenerenderer.h"
#include <algorithm>
#include <cmath>
#include <QPainter>
#include <QPainterPath>
#include <QToolTip>
#include <QDebug>
#include "pathfinder.h"
#include "yamlparser.h"
GLuint vertexbuffer;
GLuint VertexArrayID;
QSceneRenderer::QSceneRenderer(QWidget* parent): QOpenGLWidget(parent)
{
setFocusPolicy(Qt::FocusPolicy::StrongFocus);
setMouseTracking(true);
installEventFilter(this);
QImage img = QImage("/home/bart/project/tiletest/images/heightmap.jpg").convertToFormat(QImage::Format_RGB32);
QImage typeImg = QImage("/home/bart/project/tiletest/images/typemap.png").convertToFormat(QImage::Format_RGB32);
QImage riverImg = QImage("/home/bart/project/tiletest/images/rivermap.png").convertToFormat(QImage::Format_RGB32);
m_zoom = 4;
m_point = new Matrix2D<TerrainPoint>(img.width(), img.height());
m_numPoints05 = img.width() / MAP_WIDTH / 1.5 / 2;
for (int y = 0; y < img.height(); y++) {
QRgb *line = (QRgb *) img.scanLine(img.height() - 1 - y);
QRgb *typeLine = !typeImg.isNull() ? (QRgb *) typeImg.scanLine(typeImg.height() - 1 - y) : nullptr;
QRgb *riverLine = !riverImg.isNull() ? (QRgb *) riverImg.scanLine(riverImg.height() - 1 - y) : nullptr;
TerrainPoint *row = m_point->row(y);
for (int x = 0; x < img.width(); x++) {
row[x].height = qRed(line[x]);
row[x].river = (riverLine && qBlue(riverLine[x]) > 120 && qRed(riverLine[x]) < 100) ? 1 : 0;
if (typeLine) {
QRgb c = typeLine[x];
int r = qRed(c), g = qGreen(c), b = qBlue(c);
int sat = std::max(r, std::max(g, b)) - std::min(r, std::min(g, b));
if (r > 150 && g < 100) row[x].type = 3; // Prairie / Red Canyon
else if (r > 150 && g > 150 && b < 100) row[x].type = 1; // Sahara
else if (b > 50 && r < 50) row[x].type = 4; // Bos (Forest)
else if (sat < 40 && g < 140) row[x].type = 2; // Tundra
else row[x].type = 0; // Grasland
} else {
row[x].type = 0;
}
}
}
loadCameraConfig();
zRot = 90.0f; // Default looking North (+Y)
m_zoom = 8.0f;
cameraUpDirection = cameraTransformation * QVector3D(0, 1, 0);
cameraTransformation.rotate(zRot, 0, 0, 1);
cameraPosition = QVector3D(28.0f, 16.0f, m_zoom); // South of the alpine mountain, looking North
tiles = new Matrix2D<Tile>(MAP_WIDTH, MAP_HEIGHT);
loadRiverMap(riverImg);
m_draggingMinimap = false;
m_minimapZoom = 1.0f;
m_image_minimap = QImage("/home/bart/project/tiletest/images/minimap.png");
if (m_image_minimap.isNull()) {
m_image_minimap = QImage("/home/bart/project/tiletest/images/rivermap_preview.png");
}
// Initialize GameManager and spawn demo units
GameManager::instance().init("/home/bart/project/tiletest", m_point, m_numPoints05);
m_lastFrameTime = 0.0f;
m_elapsedTimer.start();
QTimer *animTimer = new QTimer(this);
connect(animTimer, &QTimer::timeout, this, QOverload<>::of(&QSceneRenderer::update));
animTimer->start(16);
m_slotLongPressTimer = new QTimer(this);
m_slotLongPressTimer->setSingleShot(true);
connect(m_slotLongPressTimer, &QTimer::timeout, this, [this]() {
onSlotLongPressTimeout();
});
}
void QSceneRenderer::loadCameraConfig()
{
QString configPath = "/home/bart/project/tiletest/config/game.yaml";
YamlNode root = YamlParser::parseFile(configPath);
if (root.hasChild("camera")) {
YamlNode cam = root["camera"];
m_cameraMinZoom = cam["min_zoom"].asFloat(m_cameraMinZoom);
m_cameraMaxZoom = cam["max_zoom"].asFloat(m_cameraMaxZoom);
m_cameraPitchMinDeg = cam["pitch_min_deg"].asFloat(m_cameraPitchMinDeg);
m_cameraPitchMaxDeg = cam["pitch_max_deg"].asFloat(m_cameraPitchMaxDeg);
}
}
float QSceneRenderer::getCameraPitchDeg() const
{
float range = m_cameraMaxZoom - m_cameraMinZoom;
float zoomT = range > 1e-4f ? std::max(0.0f, std::min(1.0f, (m_zoom - m_cameraMinZoom) / range)) : 0.0f;
return m_cameraPitchMinDeg + zoomT * (m_cameraPitchMaxDeg - m_cameraPitchMinDeg);
}
float QSceneRenderer::getCameraForwardDist() const
{
float pitchRad = getCameraPitchDeg() * PI / 180.0f;
float tanPitch = std::tan(pitchRad);
if (tanPitch < 1e-4f) tanPitch = 1e-4f;
return m_zoom / tanPitch;
}
QVector3D QSceneRenderer::getCameraLookAt() const
{
float forwardDist = getCameraForwardDist();
float rad = zRot * PI / 180.0f;
return QVector3D(
cameraPosition.x() + forwardDist * cos(rad),
cameraPosition.y() + forwardDist * sin(rad),
0.0f
);
}
void QSceneRenderer::loadRiverMap(const QImage &riverImg)
{
if (riverImg.isNull()) return;
float scaleX = m_numPoints05 * 2.0f;
float scaleY = m_numPoints05 / LOD_0_SIZE_Y;
for (int y = 0; y < MAP_HEIGHT; ++y) {
for (int x = 0; x < MAP_WIDTH; ++x) {
float worldX = x * (LOD_0_SIZE * 0.75f);
float worldY = y * (LOD_0_SIZE_Y * 2.0f) + ((x % 2 == 0) ? 0.0f : LOD_0_SIZE_Y);
for (int e = 0; e < 6; ++e) {
QVector2D mid = getHexEdgeMidpoint(e);
float midX = worldX + mid.x();
float midY = worldY + mid.y();
int px = (int) round(midX * scaleX);
int py = (int) round(midY * scaleY);
bool hasRiver = false;
for (int dy = -1; dy <= 1 && !hasRiver; ++dy) {
for (int dx = -1; dx <= 1 && !hasRiver; ++dx) {
int sx = px + dx;
int sy = py + dy;
if (sx >= 0 && sx < riverImg.width() && sy >= 0 && sy < riverImg.height()) {
QRgb c = riverImg.pixel(sx, riverImg.height() - 1 - sy);
if (qBlue(c) > 120 && qRed(c) < 100) {
hasRiver = true;
}
}
}
}
if (hasRiver) {
tiles->operator()(x, y).addRiverEdge(e);
int nx, ny;
getHexNeighbor(x, y, e, nx, ny);
if (nx >= 0 && nx < MAP_WIDTH && ny >= 0 && ny < MAP_HEIGHT) {
tiles->operator()(nx, ny).addRiverEdge((e + 3) % 6);
}
}
}
}
}
}
Tile * QSceneRenderer::getTile(int x, int y)
{
return &tiles->operator()(x, y);
}
QRect QSceneRenderer::getMinimapRect() const
{
int maxW = std::min(480, std::max(200, width() / 3));
int mapW = maxW;
int mapH = (int) round(mapW / (225.0f / 108.22f));
if (mapH > 500) mapH = 500;
if (mapW > 500) mapW = 500;
int margin = 16;
return QRect(margin, height() - mapH - margin - 22, mapW, mapH);
}
QRect QSceneRenderer::getMinimapCardRect() const
{
QRect m = getMinimapRect();
return m.adjusted(-6, -6, 6, 24);
}
QRect QSceneRenderer::getZoomOutButtonRect() const
{
QRect card = getMinimapCardRect();
return QRect(card.left() + 4, card.top() - 28, 30, 22);
}
QRect QSceneRenderer::getZoomResetButtonRect() const
{
QRect card = getMinimapCardRect();
return QRect(card.left() + 38, card.top() - 28, 48, 22);
}
QRect QSceneRenderer::getZoomInButtonRect() const
{
QRect card = getMinimapCardRect();
return QRect(card.left() + 90, card.top() - 28, 30, 22);
}
QRect QSceneRenderer::getMinimapSourceRect() const
{
if (m_image_minimap.isNull()) return QRect();
int imgW = m_image_minimap.width();
int imgH = m_image_minimap.height();
if (m_minimapZoom <= 1.01f) {
return QRect(0, 0, imgW, imgH);
}
int srcW = (int) round(imgW / m_minimapZoom);
int srcH = (int) round(imgH / m_minimapZoom);
float u = std::max(0.0f, std::min(1.0f, cameraPosition.x() / 225.0f));
float v = std::max(0.0f, std::min(1.0f, 1.0f - (cameraPosition.y() / 108.22f)));
int camX = (int) round(u * imgW);
int camY = (int) round(v * imgH);
int srcX = camX - srcW / 2;
int srcY = camY - srcH / 2;
if (srcX < 0) srcX = 0;
if (srcY < 0) srcY = 0;
if (srcX + srcW > imgW) srcX = imgW - srcW;
if (srcY + srcH > imgH) srcY = imgH - srcH;
return QRect(srcX, srcY, srcW, srcH);
}
QPointF QSceneRenderer::worldToMinimap(float wx, float wy, const QRect &mapRect) const
{
QRect src = getMinimapSourceRect();
if (src.isEmpty() || m_image_minimap.isNull() || m_minimapZoom <= 1.01f) {
float u = std::max(0.0f, std::min(1.0f, wx / 225.0f));
float v = std::max(0.0f, std::min(1.0f, 1.0f - (wy / 108.22f)));
return QPointF(mapRect.left() + u * mapRect.width(), mapRect.top() + v * mapRect.height());
}
float imgW = m_image_minimap.width();
float imgH = m_image_minimap.height();
float uMin = (float)src.left() / imgW;
float uMax = (float)src.right() / imgW;
float vMin = (float)src.top() / imgH;
float vMax = (float)src.bottom() / imgH;
float u = wx / 225.0f;
float v = 1.0f - (wy / 108.22f);
float relU = (u - uMin) / (uMax - uMin);
float relV = (v - vMin) / (vMax - vMin);
return QPointF(mapRect.left() + relU * mapRect.width(), mapRect.top() + relV * mapRect.height());
}
QVector2D QSceneRenderer::minimapToWorld(const QPoint &pos, const QRect &mapRect) const
{
QRect src = getMinimapSourceRect();
float relU = std::max(0.0f, std::min(1.0f, (float)(pos.x() - mapRect.left()) / mapRect.width()));
float relV = std::max(0.0f, std::min(1.0f, (float)(pos.y() - mapRect.top()) / mapRect.height()));
if (src.isEmpty() || m_image_minimap.isNull() || m_minimapZoom <= 1.01f) {
float wx = relU * 225.0f;
float wy = (1.0f - relV) * 108.22f;
return QVector2D(wx, wy);
}
float imgW = m_image_minimap.width();
float imgH = m_image_minimap.height();
float uMin = (float)src.left() / imgW;
float uMax = (float)src.right() / imgW;
float vMin = (float)src.top() / imgH;
float vMax = (float)src.bottom() / imgH;
float u = uMin + relU * (uMax - uMin);
float v = vMin + relV * (vMax - vMin);
float wx = std::max(0.0f, std::min(225.0f, u * 225.0f));
float wy = std::max(0.0f, std::min(108.22f, (1.0f - v) * 108.22f));
return QVector2D(wx, wy);
}
void QSceneRenderer::drawMinimap(QPainter &painter)
{
QRect mapRect = getMinimapRect();
if (mapRect.width() <= 10 || mapRect.height() <= 10) return;
painter.setRenderHint(QPainter::Antialiasing, true);
// 1. Draw Zoom Buttons above card
QRect btnOut = getZoomOutButtonRect();
QRect btnReset = getZoomResetButtonRect();
QRect btnIn = getZoomInButtonRect();
// Zoom Out [-]
painter.setBrush(QColor(22, 28, 40, 230));
painter.setPen(QPen(QColor(80, 110, 150, 200), 1.2));
painter.drawRoundedRect(btnOut, 4, 4);
painter.setFont(QFont("sans-serif", 10, QFont::Bold));
painter.setPen(QColor(220, 230, 245));
painter.drawText(btnOut, Qt::AlignCenter, "-");
// Zoom Indicator / Reset [Full / 2.0x]
painter.setBrush(QColor(22, 28, 40, 230));
painter.setPen(QPen(QColor(80, 110, 150, 200), 1.2));
painter.drawRoundedRect(btnReset, 4, 4);
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(m_minimapZoom > 1.01f ? QColor(255, 220, 80) : QColor(200, 215, 235));
QString zoomText = m_minimapZoom <= 1.01f ? "Full" : QString("%1x").arg(m_minimapZoom, 0, 'f', 1);
painter.drawText(btnReset, Qt::AlignCenter, zoomText);
// Zoom In [+]
painter.setBrush(QColor(22, 28, 40, 230));
painter.setPen(QPen(QColor(80, 110, 150, 200), 1.2));
painter.drawRoundedRect(btnIn, 4, 4);
painter.setFont(QFont("sans-serif", 10, QFont::Bold));
painter.setPen(QColor(220, 230, 245));
painter.drawText(btnIn, Qt::AlignCenter, "+");
// Title label next to zoom buttons:
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(160, 195, 230));
QRect titleRect(btnIn.right() + 8, btnIn.top(), 180, btnIn.height());
painter.drawText(titleRect, Qt::AlignLeft | Qt::AlignVCenter, "HEX MINIMAP");
// 2. Semi-transparent dark background card
QRect cardRect = getMinimapCardRect();
QColor cardBg(20, 24, 32, 225);
painter.setBrush(cardBg);
painter.setPen(QPen(QColor(80, 110, 150, 180), 1.5));
painter.drawRoundedRect(cardRect, 8, 8);
// 3. Draw map image inside mapRect (with source crop for zoom)
QRect srcRect = getMinimapSourceRect();
if (!m_image_minimap.isNull()) {
painter.drawImage(mapRect, m_image_minimap, srcRect);
} else {
painter.fillRect(mapRect, QColor(30, 40, 60));
}
// Border around map image
painter.setBrush(Qt::NoBrush);
painter.setPen(QPen(QColor(120, 150, 180, 160), 1.0));
painter.drawRect(mapRect);
// Draw roads on minimap
for (const auto &pair : GameManager::instance().getTileRoads()) {
const TileRoad &road = pair.second;
QVector2D center = Pathfinder::hexToWorld(road.tileX, road.tileY);
QPointF mapP1 = worldToMinimap(center.x(), center.y(), mapRect);
if (!mapRect.contains(mapP1.toPoint())) continue;
const RoadTypeConfig *rCfg = GameManager::instance().roadConfigs().getRoadType(road.roadTypeId);
QColor roadCol = rCfg ? rCfg->getColor() : QColor(194, 166, 117);
painter.setPen(QPen(roadCol, 1.2f));
if (road.connectionMask == 0) {
painter.setBrush(roadCol);
painter.drawEllipse(mapP1, 1.2, 1.2);
} else {
for (int e = 0; e < 6; ++e) {
if (road.hasConnection(e)) {
QVector2D edgeMid = center + getHexEdgeMidpoint(e);
QPointF mapP2 = worldToMinimap(edgeMid.x(), edgeMid.y(), mapRect);
painter.drawLine(mapP1, mapP2);
}
}
}
}
// Draw unit dots on minimap
for (const auto &u : GameManager::instance().getUnits()) {
if (u->isDead()) continue;
QPointF p = worldToMinimap(u->worldPos.x(), u->worldPos.y(), mapRect);
if (mapRect.contains(p.toPoint())) {
const Player *pl = GameManager::instance().players().getPlayer(u->playerId);
QColor dotCol = pl ? pl->primaryColor : Qt::yellow;
painter.setBrush(dotCol);
painter.setPen(QPen(Qt::white, 1.0));
painter.drawEllipse(p, 3.0, 3.0);
}
}
// 4. Camera view representation (clipped to mapRect):
QPointF camCenter = worldToMinimap(cameraPosition.x(), cameraPosition.y(), mapRect);
float rad = zRot * PI / 180.0f;
float fovHalf = 25.0f * PI / 180.0f;
float pitchDeg = getCameraPitchDeg();
float farPitchRad = (pitchDeg - 22.5f) * PI / 180.0f;
float viewDist = std::max(4.0f, (float)(m_zoom / std::tan(farPitchRad)));
float lx = cameraPosition.x() + viewDist * cos(rad - fovHalf);
float ly = cameraPosition.y() + viewDist * sin(rad - fovHalf);
float rx = cameraPosition.x() + viewDist * cos(rad + fovHalf);
float ry = cameraPosition.y() + viewDist * sin(rad + fovHalf);
float cx = cameraPosition.x() + viewDist * cos(rad);
float cy = cameraPosition.y() + viewDist * sin(rad);
QPointF sLeft = worldToMinimap(lx, ly, mapRect);
QPointF sRight = worldToMinimap(rx, ry, mapRect);
QPointF sCenter = worldToMinimap(cx, cy, mapRect);
painter.save();
painter.setClipRect(mapRect);
QPolygonF cone;
cone << camCenter << sLeft << sCenter << sRight;
painter.setBrush(QColor(255, 230, 50, 60));
painter.setPen(QPen(QColor(255, 235, 70, 200), 1.5));
painter.drawPolygon(cone);
painter.setPen(QPen(QColor(255, 240, 100, 180), 1.0, Qt::DashLine));
painter.drawLine(camCenter, sCenter);
painter.setBrush(QColor(0, 0, 0, 140));
painter.setPen(Qt::NoPen);
painter.drawEllipse(camCenter, 5.0, 5.0);
painter.setBrush(QColor(255, 50, 50));
painter.setPen(QPen(Qt::white, 1.5));
painter.drawEllipse(camCenter, 3.5, 3.5);
painter.restore();
// 5. North Compass Indicator
QPoint compassCenter(mapRect.right() - 16, mapRect.top() + 16);
painter.setBrush(QColor(15, 20, 30, 180));
painter.setPen(QPen(QColor(100, 140, 180, 200), 1.0));
painter.drawEllipse(compassCenter, 10, 10);
painter.setPen(QPen(QColor(240, 50, 50), 2.0));
painter.drawLine(compassCenter, QPoint(compassCenter.x(), compassCenter.y() - 7));
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(240, 60, 60));
painter.drawText(QRect(compassCenter.x() - 6, compassCenter.y() - 18, 12, 10), Qt::AlignCenter, "N");
// 6. Coordinates bar
QRect bottomBar(cardRect.left() + 6, mapRect.bottom() + 4, cardRect.width() - 12, 16);
painter.setFont(QFont("sans-serif", 8));
painter.setPen(QColor(180, 200, 220));
QString info = QString("Cam: %1, %2 km | Alt: %3 km | Hoek: %4°")
.arg(cameraPosition.x(), 0, 'f', 1)
.arg(cameraPosition.y(), 0, 'f', 1)
.arg(m_zoom, 0, 'f', 1)
.arg(pitchDeg, 0, 'f', 0);
painter.drawText(bottomBar, Qt::AlignLeft | Qt::AlignVCenter, info);
}
QImage QSceneRenderer::getTintedIcon(const QString &path, const QColor &color)
{
QString cacheKey = path + "_" + color.name();
if (m_tintedIconCache.contains(cacheKey)) {
return m_tintedIconCache[cacheKey];
}
if (!m_iconCache.contains(path)) {
QString fullPath = path.startsWith("/") ? path : ("/home/bart/project/tiletest/" + path);
QImage loaded(fullPath);
if (loaded.isNull()) {
loaded = QImage(32, 32, QImage::Format_ARGB32);
loaded.fill(Qt::transparent);
QPainter p(&loaded);
p.setRenderHint(QPainter::Antialiasing, true);
p.setBrush(Qt::white);
p.setPen(Qt::NoPen);
p.drawEllipse(6, 6, 20, 20);
}
m_iconCache[path] = loaded;
}
QImage base = m_iconCache[path];
QImage tinted(base.size(), QImage::Format_ARGB32);
tinted.fill(Qt::transparent);
QPainter p(&tinted);
p.drawImage(0, 0, base);
p.setCompositionMode(QPainter::CompositionMode_SourceIn);
p.fillRect(tinted.rect(), color);
p.end();
m_tintedIconCache[cacheKey] = tinted;
return tinted;
}
bool QSceneRenderer::screenToHex(const QPoint &screenPos, const QMatrix4x4 &vMatrix, int &outX, int &outY)
{
float ndcX = (2.0f * screenPos.x()) / width() - 1.0f;
float ndcY = 1.0f - (2.0f * screenPos.y()) / height();
QMatrix4x4 invVP = (Projection * vMatrix).inverted();
QVector4D pNear = invVP.map(QVector4D(ndcX, ndcY, -1.0f, 1.0f));
QVector4D pFar = invVP.map(QVector4D(ndcX, ndcY, 1.0f, 1.0f));
if (std::abs(pNear.w()) < 1e-6f || std::abs(pFar.w()) < 1e-6f) return false;
QVector3D rayStart = pNear.toVector3DAffine();
QVector3D rayEnd = pFar.toVector3DAffine();
QVector3D rayDir = (rayEnd - rayStart).normalized();
if (rayDir.z() >= -0.001f) return false;
float t = (2.0f - rayStart.z()) / rayDir.z();
if (t < 0.0f) return false;
QVector3D hit = rayStart + t * rayDir;
for (int iter = 0; iter < 4; ++iter) {
float h = Unit::getTerrainHeight(hit.x(), hit.y(), m_point, m_numPoints05);
float dt = (h - hit.z()) / rayDir.z();
hit += dt * rayDir;
}
Pathfinder::worldToHex(hit.x(), hit.y(), outX, outY);
return (outX >= 0 && outX < MAP_WIDTH && outY >= 0 && outY < MAP_HEIGHT);
}
void QSceneRenderer::drawTickBar(QPainter &painter)
{
painter.setRenderHint(QPainter::Antialiasing, true);
int barW = 690, barH = 34;
QRect barRect(16, 16, barW, barH);
// Dark glass card
painter.setBrush(QColor(18, 24, 34, 235));
painter.setPen(QPen(QColor(70, 100, 140, 180), 1.2));
painter.drawRoundedRect(barRect, 6, 6);
// 1. Movement Tick Info
float tickDur = GameManager::instance().getTickDuration();
float curTick = GameManager::instance().getTickTimer();
float progress = curTick / tickDur;
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(210, 225, 245));
QString tickLabel = QString("Tick: %1s / %2s").arg(curTick, 0, 'f', 1).arg(tickDur, 0, 'f', 0);
painter.drawText(QRect(barRect.left() + 10, barRect.top() + 4, 120, 14), Qt::AlignLeft | Qt::AlignVCenter, tickLabel);
// Progress bar
QRect progBg(barRect.left() + 10, barRect.top() + 20, 130, 6);
painter.setBrush(QColor(30, 40, 55));
painter.setPen(Qt::NoPen);
painter.drawRoundedRect(progBg, 3, 3);
QRect progFg(barRect.left() + 10, barRect.top() + 20, (int)(130 * progress), 6);
painter.setBrush(QColor(60, 180, 255));
painter.drawRoundedRect(progFg, 3, 3);
// 2. Player 1 National Resources (Empire-wide)
const Player *p1 = GameManager::instance().players().getPlayer(1);
if (p1) {
struct ResItem { QString icon; int val; QColor col; };
std::vector<ResItem> natRes = {
{"images/resources/cultuur.png", p1->culture, QColor(215, 110, 255)},
{"images/resources/kennis.png", p1->science, QColor(60, 190, 255)},
{"images/resources/goud.png", p1->gold, QColor(255, 210, 50)},
{"images/resources/geloof.png", p1->faith, QColor(255, 230, 80)}
};
int startX = barRect.left() + 155;
int itemW = 86;
for (size_t i = 0; i < natRes.size(); ++i) {
int ix = startX + (int)i * itemW;
QImage icon = getTintedIcon(natRes[i].icon, natRes[i].col);
painter.drawImage(QRect(ix, barRect.top() + 9, 16, 16), icon);
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(natRes[i].col);
painter.drawText(QRect(ix + 20, barRect.top() + 8, itemW - 22, 18), Qt::AlignLeft | Qt::AlignVCenter, QString("%L1").arg(natRes[i].val));
}
}
// 3. Sim Speed Buttons [1x] [2x] [5x]
float curSpeed = GameManager::instance().getSimSpeed();
m_speed1xRect = QRect(barRect.right() - 150, barRect.top() + 6, 42, 22);
m_speed2xRect = QRect(barRect.right() - 102, barRect.top() + 6, 42, 22);
m_speed5xRect = QRect(barRect.right() - 54, barRect.top() + 6, 42, 22);
auto drawSpeedBtn = [&](const QRect &r, const QString &text, bool active) {
painter.setBrush(active ? QColor(40, 70, 110, 240) : QColor(25, 32, 45, 220));
painter.setPen(QPen(active ? QColor(255, 215, 60) : QColor(80, 110, 150), active ? 1.5 : 1.0));
painter.drawRoundedRect(r, 4, 4);
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(active ? QColor(255, 225, 80) : QColor(200, 215, 235));
painter.drawText(r, Qt::AlignCenter, text);
};
drawSpeedBtn(m_speed1xRect, "1x", std::abs(curSpeed - 1.0f) < 0.1f);
drawSpeedBtn(m_speed2xRect, "2x", std::abs(curSpeed - 2.0f) < 0.1f);
drawSpeedBtn(m_speed5xRect, "5x", std::abs(curSpeed - 5.0f) < 0.1f);
}
void QSceneRenderer::drawUnitsOverlay(QPainter &painter, const QMatrix4x4 &vMatrix)
{
m_activeActionBubbles.clear();
m_cityBannerHits.clear();
painter.setRenderHint(QPainter::Antialiasing, true);
Unit *selectedUnit = GameManager::instance().getSelectedUnit();
// 1. Draw City Banners (if not in city view)
if (!GameManager::instance().isInCityView()) {
for (const auto &c : GameManager::instance().getCities()) {
bool inFront = false;
QPointF screenP = UnitRenderer::projectToScreen(c.worldPos + QVector3D(0, 0, 0.65f), Projection, vMatrix, width(), height(), inFront);
if (!inFront) continue;
const Player *pl = GameManager::instance().players().getPlayer(c.playerId);
QColor col = pl ? pl->primaryColor : Qt::blue;
int bWidth = c.hasActiveProject() ? 150 : 110;
QRect bannerRect((int)screenP.x() - bWidth / 2, (int)screenP.y() - 14, bWidth, 22);
painter.setBrush(QColor(20, 24, 34, 235));
painter.setPen(QPen(col, 2.0));
painter.drawRoundedRect(bannerRect, 4, 4);
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(Qt::white);
QString bannerText = QString("%1 (%2 Pop)").arg(c.name).arg(c.getTotalPop());
if (c.hasActiveProject()) {
int pct = (int)(c.currentProject.getProgress() * 100.0f);
bannerText += QString(" | %1 %2%").arg(c.currentProject.name).arg(pct);
}
painter.drawText(bannerRect, Qt::AlignCenter, bannerText);
m_cityBannerHits.push_back({bannerRect, c.id});
}
}
// 2. Draw Units: Teardrop (Druppel in secondary color) + Icon (in primary color)
const float R = 14.0f; // Head circle radius
City *activeCityView = GameManager::instance().getActiveCityView();
int maxCityRadius = TerrainYieldManager::instance().getCityConfig().maxWorkableRadius;
for (const auto &unit : GameManager::instance().getUnits()) {
if (unit->isDead()) continue;
if (activeCityView) {
if (Pathfinder::hexDistance(activeCityView->tileX, activeCityView->tileY, unit->tileX, unit->tileY) > maxCityRadius) {
continue;
}
}
bool inFront = false;
QVector3D topCylinder = unit->worldPos + QVector3D(0.0f, 0.0f, 0.38f);
QPointF tipPos = UnitRenderer::projectToScreen(topCylinder, Projection, vMatrix, width(), height(), inFront);
if (!inFront) continue;
if (tipPos.x() < -60 || tipPos.x() > width() + 60 || tipPos.y() < -60 || tipPos.y() > height() + 60) {
continue;
}
const Player *player = GameManager::instance().players().getPlayer(unit->playerId);
QColor primaryCol = player ? player->primaryColor : Qt::blue;
QColor secondaryCol = player ? player->secondaryColor : Qt::white;
QPointF headCenter(tipPos.x(), tipPos.y() - 25.0f);
// Teardrop path (pointing down to top of cylinder)
QPainterPath teardrop;
teardrop.moveTo(tipPos);
teardrop.lineTo(headCenter.x() - R * 0.88f, headCenter.y() + R * 0.45f);
teardrop.arcTo(headCenter.x() - R, headCenter.y() - R, R * 2.0f, R * 2.0f, 205.0f, -230.0f);
teardrop.lineTo(tipPos);
teardrop.closeSubpath();
// Drop shadow
painter.save();
painter.translate(1.0, 1.5);
painter.fillPath(teardrop, QColor(0, 0, 0, 70));
painter.restore();
// Fill teardrop with secondary color
painter.fillPath(teardrop, secondaryCol);
// Border of teardrop
painter.setPen(QPen(QColor(25, 30, 40, 220), 1.6));
painter.drawPath(teardrop);
// Inside the druppel: Draw unit icon tinted in player primary color
QImage icon = getTintedIcon(unit->config.iconPath, primaryCol);
QRectF iconRect(headCenter.x() - 9.0f, headCenter.y() - 9.0f, 18.0f, 18.0f);
painter.drawImage(iconRect, icon);
// Health bar above head
float hpFrac = std::max(0.0f, std::min(1.0f, unit->health / unit->maxHealth));
QRectF hpBg(headCenter.x() - 14.0f, headCenter.y() - R - 6.0f, 28.0f, 3.5f);
painter.setBrush(QColor(30, 30, 30, 200));
painter.setPen(Qt::NoPen);
painter.drawRoundedRect(hpBg, 1.5, 1.5);
QRectF hpFg(headCenter.x() - 14.0f, headCenter.y() - R - 6.0f, 28.0f * hpFrac, 3.5f);
painter.setBrush(hpFrac > 0.5f ? QColor(70, 220, 90) : (hpFrac > 0.25f ? QColor(255, 190, 40) : QColor(240, 50, 50)));
painter.drawRoundedRect(hpFg, 1.5, 1.5);
// Building progress bar if unit is constructing
if (unit->isBuilding()) {
QRectF bpBg(headCenter.x() - 14.0f, headCenter.y() - R - 1.5f, 28.0f, 3.5f);
painter.setBrush(QColor(30, 30, 30, 220));
painter.setPen(Qt::NoPen);
painter.drawRoundedRect(bpBg, 1.5, 1.5);
float bpFrac = std::max(0.0f, std::min(1.0f, unit->buildProgress));
QRectF bpFg(headCenter.x() - 14.0f, headCenter.y() - R - 1.5f, 28.0f * bpFrac, 3.5f);
painter.setBrush(QColor(255, 185, 45));
painter.drawRoundedRect(bpFg, 1.5, 1.5);
}
// If unit is selected: pulsing selection halo & floating action bubbles
if (unit.get() == selectedUnit) {
float pulse = (std::sin(m_elapsedTimer.elapsed() * 0.008f) + 1.0f) * 0.5f;
painter.setPen(QPen(QColor(255, 220, 40, (int)(180 + pulse * 75)), 2.5));
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(headCenter, R + 3.0f, R + 3.0f);
// Floating Action Bubbles above the unit's druppel
const QStringList &actions = unit->config.actions;
int numActions = actions.size();
float bubbleR = 12.0f;
float spacing = 28.0f;
float startX = headCenter.x() - ((numActions - 1) * spacing) / 2.0f;
float bubbleY = headCenter.y() - R - 20.0f;
for (int a = 0; a < numActions; ++a) {
QString act = actions[a];
QPointF bCenter(startX + a * spacing, bubbleY);
QRect bRect((int)(bCenter.x() - bubbleR), (int)(bCenter.y() - bubbleR), (int)(bubbleR * 2), (int)(bubbleR * 2));
bool isActive = false;
QString iconFile = "images/actions/move.png";
QString tip = "Verplaatsen (M)";
if (act == "verplaatsen") {
isActive = (GameManager::instance().currentActionMode() == ActionMode::Move);
iconFile = "images/actions/move.png";
tip = "Verplaatsen (M)";
} else if (act == "aanvallen") {
isActive = (GameManager::instance().currentActionMode() == ActionMode::Attack);
iconFile = "images/actions/attack.png";
tip = Localization::instance().get("ui.unit.tip_attack", "Attack (A) [Range: %1]").arg(unit->config.attackRange);
} else if (act == "steden_bouwen" || act == "build_city") {
bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildCity);
isActive = (GameManager::instance().currentActionMode() == ActionMode::BuildCity) || isQueued;
iconFile = "images/actions/build_city.png";
tip = isQueued
? Localization::instance().get("ui.unit.city_queued", "Found City queued on arrival")
: Localization::instance().get("ui.unit.tip_build_city", "Found City (B)");
} else if (act == "boerderij_bouwen" || act == "build_farm") {
bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildImprovement);
isActive = (unit->isBuilding() && unit->buildActionType != "road") || isQueued;
iconFile = "images/actions/farm.png";
if (unit->isBuilding() && unit->buildActionType != "road") {
int pct = (int)(std::min(1.0f, unit->buildProgress) * 100.0f);
tip = Localization::instance().get("ui.unit.tip_farm_building", "Busy building... %1%").arg(pct);
} else if (isQueued) {
tip = Localization::instance().get("ui.unit.tip_farm_queued", "Build Farm (Queued on arrival - click to cancel)");
} else {
tip = Localization::instance().get("ui.unit.tip_build_farm", "Build Farm (F)");
}
} else if (act == "build_road" || act == "weg_bouwen") {
bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildRoad);
isActive = (unit->isBuilding() && unit->buildActionType == "road") || isQueued;
iconFile = "images/actions/road.png";
if (unit->isBuilding() && unit->buildActionType == "road") {
int pct = (int)(std::min(1.0f, unit->buildProgress) * 100.0f);
tip = Localization::instance().get("ui.unit.tip_road_building", "Busy building road... %1%").arg(pct);
} else if (isQueued) {
tip = Localization::instance().get("ui.unit.tip_road_queued", "Build Road (Queued on arrival - click to cancel)");
} else {
tip = Localization::instance().get("ui.unit.tip_build_road", "Build Road (R)");
}
}
// Draw action bubble
painter.setBrush(isActive ? QColor(40, 80, 140, 240) : QColor(20, 26, 38, 230));
painter.setPen(QPen(isActive ? QColor(255, 220, 50) : secondaryCol, isActive ? 2.0 : 1.2));
painter.drawEllipse(bCenter, bubbleR, bubbleR);
// Action icon inside bubble
QImage aIcon = getTintedIcon(iconFile, isActive ? QColor(255, 230, 80) : primaryCol);
painter.drawImage(QRectF(bCenter.x() - 7, bCenter.y() - 7, 14, 14), aIcon);
// Register for click detection
m_activeActionBubbles.push_back({bRect, act, tip});
// Tooltip on hover
if (bRect.contains(m_mouseCurrentPos)) {
QRect tipRect((int)bCenter.x() - 55, (int)bCenter.y() - 26, 110, 18);
painter.setBrush(QColor(15, 20, 30, 240));
painter.setPen(QPen(QColor(120, 160, 210), 1.0));
painter.drawRoundedRect(tipRect, 3, 3);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(Qt::white);
painter.drawText(tipRect, Qt::AlignCenter, tip);
}
}
}
}
}
void QSceneRenderer::drawPathAndRange(QPainter &painter, Unit *unit, const QMatrix4x4 &vMatrix)
{
if (!unit) return;
painter.setRenderHint(QPainter::Antialiasing, true);
// 1. If unit is moving or has a path: draw path line and destination reticle
if (unit->path.size() > 1) {
QPainterPath pathLine;
bool first = true;
for (const auto &p : unit->path) {
QVector2D w = Pathfinder::hexToWorld(p.first, p.second);
float z = Unit::getTerrainHeight(w.x(), w.y(), m_point, m_numPoints05);
bool inFront = false;
QPointF sPos = UnitRenderer::projectToScreen(QVector3D(w.x(), w.y(), z + 0.05f), Projection, vMatrix, width(), height(), inFront);
if (inFront) {
if (first) {
pathLine.moveTo(sPos);
first = false;
} else {
pathLine.lineTo(sPos);
}
}
}
painter.setPen(QPen(QColor(80, 220, 255, 200), 2.2, Qt::DashLine));
painter.setBrush(Qt::NoBrush);
painter.drawPath(pathLine);
// Destination target reticle
const auto &lastHex = unit->path.back();
QVector2D destW = Pathfinder::hexToWorld(lastHex.first, lastHex.second);
float destZ = Unit::getTerrainHeight(destW.x(), destW.y(), m_point, m_numPoints05);
bool inFront = false;
QPointF sDest = UnitRenderer::projectToScreen(QVector3D(destW.x(), destW.y(), destZ + 0.05f), Projection, vMatrix, width(), height(), inFront);
if (inFront) {
painter.setPen(QPen(QColor(255, 220, 60), 2.0));
painter.drawEllipse(sDest, 10.0, 10.0);
painter.drawLine(sDest.x() - 14, sDest.y(), sDest.x() + 14, sDest.y());
painter.drawLine(sDest.x(), sDest.y() - 14, sDest.x(), sDest.y() + 14);
}
}
// 2. Attack range visualization if Attack mode is active
if (GameManager::instance().currentActionMode() == ActionMode::Attack) {
int range = unit->config.attackRange;
for (const auto &u : GameManager::instance().getUnits()) {
if (u->isDead() || u->playerId == unit->playerId) continue;
int d = Pathfinder::hexDistance(unit->tileX, unit->tileY, u->tileX, u->tileY);
if (d <= range) {
// Target reticle over enemy unit
bool inFront = false;
QPointF sTarget = UnitRenderer::projectToScreen(u->worldPos + QVector3D(0, 0, 0.4f), Projection, vMatrix, width(), height(), inFront);
if (inFront) {
float pulse = (std::sin(m_elapsedTimer.elapsed() * 0.01f) + 1.0f) * 0.5f;
painter.setPen(QPen(QColor(255, 50, 50, (int)(180 + pulse * 75)), 2.2));
painter.setBrush(QColor(255, 40, 40, 40));
painter.drawEllipse(sTarget, 16.0f, 16.0f);
painter.drawLine(sTarget.x() - 20, sTarget.y(), sTarget.x() + 20, sTarget.y());
painter.drawLine(sTarget.x(), sTarget.y() - 20, sTarget.x(), sTarget.y() + 20);
}
}
}
}
}
void QSceneRenderer::drawFloatingTexts(QPainter &painter, const QMatrix4x4 &vMatrix)
{
painter.setRenderHint(QPainter::Antialiasing, true);
for (const auto &ft : GameManager::instance().getFloatingTexts()) {
bool inFront = false;
QPointF sPos = UnitRenderer::projectToScreen(ft.worldPos, Projection, vMatrix, width(), height(), inFront);
if (!inFront) continue;
float alpha = std::min(1.0f, ft.life / (ft.maxLife * 0.5f));
QColor col = ft.color;
col.setAlphaF(alpha);
painter.setFont(QFont("sans-serif", 10, QFont::Bold));
painter.setPen(QPen(QColor(0, 0, 0, (int)(alpha * 200)), 3.0));
// Stroke
QRect textRect((int)sPos.x() - 80, (int)sPos.y() - 12, 160, 24);
painter.drawText(textRect, Qt::AlignCenter, ft.text);
// Fill
painter.setPen(col);
painter.drawText(textRect, Qt::AlignCenter, ft.text);
}
}
void QSceneRenderer::drawUnitCard(QPainter &painter, Unit *unit)
{
if (!unit) return;
painter.setRenderHint(QPainter::Antialiasing, true);
int cardW = 310, cardH = 180;
QRect cardRect(width() - cardW - 16, height() - cardH - 16, cardW, cardH);
m_unitCardRect = cardRect;
// Dark glass card
painter.setBrush(QColor(18, 24, 34, 235));
painter.setPen(QPen(QColor(80, 115, 160, 200), 1.5));
painter.drawRoundedRect(cardRect, 8, 8);
const Player *player = GameManager::instance().players().getPlayer(unit->playerId);
QColor primaryCol = player ? player->primaryColor : Qt::blue;
QColor secondaryCol = player ? player->secondaryColor : Qt::white;
// Header badge
QRect badgeRect(cardRect.left() + 12, cardRect.top() + 12, 22, 22);
painter.setBrush(secondaryCol);
painter.setPen(QPen(QColor(30, 30, 30), 1.2));
painter.drawEllipse(badgeRect);
QImage uIcon = getTintedIcon(unit->config.iconPath, primaryCol);
painter.drawImage(QRect(badgeRect.left() + 3, badgeRect.top() + 3, 16, 16), uIcon);
// Unit Name & Player
painter.setFont(QFont("sans-serif", 11, QFont::Bold));
painter.setPen(QColor(240, 245, 255));
painter.drawText(QRect(badgeRect.right() + 10, cardRect.top() + 10, 160, 18), Qt::AlignLeft | Qt::AlignVCenter, unit->config.name.toUpper());
painter.setFont(QFont("sans-serif", 8));
painter.setPen(QColor(160, 185, 215));
QString playerInfo = QString("%1 | Hex: (%2, %3)").arg(player ? player->name : "Onbekend").arg(unit->tileX).arg(unit->tileY);
painter.drawText(QRect(badgeRect.right() + 10, cardRect.top() + 28, 200, 14), Qt::AlignLeft | Qt::AlignVCenter, playerInfo);
// Category Pill
QString catStr = unitCategoryToString(unit->config.category);
if (unit->config.isRanged()) catStr += " (Ranged)";
QRect catRect(cardRect.right() - 110, cardRect.top() + 12, 98, 18);
painter.setBrush(QColor(35, 48, 68, 200));
painter.setPen(QPen(QColor(100, 140, 190), 1.0));
painter.drawRoundedRect(catRect, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(200, 225, 255));
painter.drawText(catRect, Qt::AlignCenter, catStr);
// Separator line
painter.setPen(QPen(QColor(60, 85, 120, 150), 1.0));
painter.drawLine(cardRect.left() + 12, cardRect.top() + 48, cardRect.right() - 12, cardRect.top() + 48);
// Health Bar
float hpFrac = std::max(0.0f, std::min(1.0f, unit->health / unit->maxHealth));
QRect hpBg(cardRect.left() + 12, cardRect.top() + 56, 170, 14);
painter.setBrush(QColor(30, 40, 55));
painter.setPen(Qt::NoPen);
painter.drawRoundedRect(hpBg, 3, 3);
QRect hpFg(cardRect.left() + 12, cardRect.top() + 56, (int)(170 * hpFrac), 14);
painter.setBrush(hpFrac > 0.5f ? QColor(60, 200, 80) : (hpFrac > 0.25f ? QColor(255, 180, 40) : QColor(240, 50, 50)));
painter.drawRoundedRect(hpFg, 3, 3);
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(Qt::white);
QString hpText = QString("HP: %1 / %2").arg((int)unit->health).arg((int)unit->maxHealth);
painter.drawText(hpBg, Qt::AlignCenter, hpText);
// Stats Grid
painter.setFont(QFont("sans-serif", 8));
painter.setPen(QColor(200, 220, 240));
QString speedInfo = Localization::instance().get("ui.unit.speed_format", "Speed: %1 hex/tick").arg(unit->config.movementSpeed, 0, 'f', 1);
painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 76, 140, 14), Qt::AlignLeft | Qt::AlignVCenter, speedInfo);
QString costInfo = Localization::instance().get("ui.unit.cost_format", "Cost: %1 pts").arg(unit->config.cost);
painter.drawText(QRect(cardRect.left() + 160, cardRect.top() + 76, 130, 14), Qt::AlignLeft | Qt::AlignVCenter, costInfo);
if (unit->config.isMilitary()) {
QString combatInfo = Localization::instance().get("ui.unit.combat_format", "Attack: %1 | Range: %2 hex")
.arg(unit->config.attackDamage).arg(unit->config.attackRange);
painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 280, 14), Qt::AlignLeft | Qt::AlignVCenter, combatInfo);
} else if (unit->isBuilding()) {
int pct = (int)(std::min(1.0f, unit->buildProgress) * 100.0f);
QString buildInfo = Localization::instance().get("ui.unit.building_pct", "Building... %1%").arg(pct);
if (!unit->config.infiniteCharges && unit->buildCharges > 0) {
buildInfo += QString(" | ") + Localization::instance().get("ui.unit.charges_format", "Charges: %1").arg(unit->buildCharges);
}
painter.setPen(QColor(255, 210, 80));
painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 280, 14), Qt::AlignLeft | Qt::AlignVCenter, buildInfo);
} else if (unit->hasQueuedAction()) {
QString qSummary = unit->getQueuedActionSummary();
QString queuedInfo = Localization::instance().get("ui.unit.status_queued", "Queued: %1").arg(qSummary);
if (unit->config.isBuilder() && !unit->config.infiniteCharges && unit->buildCharges > 0) {
queuedInfo += QString(" | ") + Localization::instance().get("ui.unit.charges_format", "Charges: %1").arg(unit->buildCharges);
}
painter.setPen(QColor(100, 220, 140));
painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 280, 14), Qt::AlignLeft | Qt::AlignVCenter, queuedInfo);
} else if (unit->config.isBuilder() && !unit->config.infiniteCharges && unit->buildCharges > 0) {
QString builderInfo = Localization::instance().get("ui.unit.civilian_unit", "Civilian unit") + QString(" | ") +
Localization::instance().get("ui.unit.charges_format", "Charges: %1").arg(unit->buildCharges);
painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 280, 14), Qt::AlignLeft | Qt::AlignVCenter, builderInfo);
} else {
painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 280, 14), Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.unit.civilian_unit", "Civilian unit"));
}
// Action Bubbles Row at bottom of card
QRect actionArea(cardRect.left() + 12, cardRect.top() + 116, cardRect.width() - 24, 52);
painter.setBrush(QColor(12, 16, 24, 180));
painter.setPen(QPen(QColor(50, 75, 105, 120), 1.0));
painter.drawRoundedRect(actionArea, 6, 6);
const QStringList &actions = unit->config.actions;
int numActions = actions.size();
int btnW = (actionArea.width() - 8 * (numActions + 1)) / std::max(1, numActions);
for (int a = 0; a < numActions; ++a) {
QString act = actions[a];
QRect bRect(actionArea.left() + 8 + a * (btnW + 8), actionArea.top() + 6, btnW, 40);
bool isActive = false;
QString iconFile = "images/actions/move.png";
QString label = Localization::instance().get("ui.unit.btn_move", "Move [M]");
QString tip = Localization::instance().get("ui.unit.tip_move", "Move (M)");
if (act == "verplaatsen") {
isActive = (GameManager::instance().currentActionMode() == ActionMode::Move);
iconFile = "images/actions/move.png";
label = Localization::instance().get("ui.unit.btn_move", "Move [M]");
tip = Localization::instance().get("ui.unit.tip_move", "Move (M)");
} else if (act == "aanvallen") {
isActive = (GameManager::instance().currentActionMode() == ActionMode::Attack);
iconFile = "images/actions/attack.png";
label = Localization::instance().get("ui.unit.btn_attack", "Attack [A]");
tip = Localization::instance().get("ui.unit.tip_attack", "Attack (A) [Range: %1]").arg(unit->config.attackRange);
} else if (act == "steden_bouwen" || act == "build_city") {
bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildCity);
isActive = (GameManager::instance().currentActionMode() == ActionMode::BuildCity) || isQueued;
iconFile = "images/actions/build_city.png";
label = isQueued ? "Queued" : Localization::instance().get("ui.unit.btn_build_city", "Settle [B]");
tip = isQueued
? Localization::instance().get("ui.unit.city_queued", "Found City queued on arrival")
: Localization::instance().get("ui.unit.tip_build_city", "Found City (B)");
} else if (act == "boerderij_bouwen" || act == "build_farm") {
bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildImprovement);
isActive = (unit->isBuilding() && unit->buildActionType != "road") || isQueued;
iconFile = "images/actions/farm.png";
if (unit->isBuilding() && unit->buildActionType != "road") {
int pct = (int)(std::min(1.0f, unit->buildProgress) * 100.0f);
label = Localization::instance().get("ui.unit.btn_farm_building", "Building %1%").arg(pct);
tip = Localization::instance().get("ui.unit.tip_farm_building", "Busy building... %1%").arg(pct);
} else if (isQueued) {
label = Localization::instance().get("ui.unit.btn_farm_queued", "Farm (Queued)");
tip = Localization::instance().get("ui.unit.tip_farm_queued", "Build Farm (Queued on arrival - click to cancel)");
} else {
label = Localization::instance().get("ui.unit.btn_build_farm", "Farm [F]");
tip = Localization::instance().get("ui.unit.tip_build_farm", "Build Farm (F)");
}
} else if (act == "build_road" || act == "weg_bouwen") {
bool isQueued = unit->hasQueuedActionType(UnitActionType::BuildRoad);
isActive = (unit->isBuilding() && unit->buildActionType == "road") || isQueued;
iconFile = "images/actions/road.png";
if (unit->isBuilding() && unit->buildActionType == "road") {
int pct = (int)(std::min(1.0f, unit->buildProgress) * 100.0f);
label = Localization::instance().get("ui.unit.btn_road_building", "Building %1%").arg(pct);
tip = Localization::instance().get("ui.unit.tip_road_building", "Busy building road... %1%").arg(pct);
} else if (isQueued) {
label = Localization::instance().get("ui.unit.btn_road_queued", "Road (Queued)");
tip = Localization::instance().get("ui.unit.tip_road_queued", "Build Road (Queued on arrival - click to cancel)");
} else {
label = Localization::instance().get("ui.unit.btn_build_road", "Road [R]");
tip = Localization::instance().get("ui.unit.tip_build_road", "Build Road (R)");
}
}
// Circular bubble button
painter.setBrush(isActive ? QColor(45, 85, 145, 250) : QColor(24, 32, 46, 220));
painter.setPen(QPen(isActive ? QColor(255, 220, 50) : QColor(80, 110, 150), isActive ? 1.8 : 1.0));
painter.drawRoundedRect(bRect, 6, 6);
// Icon inside button
QImage aIcon = getTintedIcon(iconFile, isActive ? QColor(255, 230, 80) : primaryCol);
painter.drawImage(QRect(bRect.left() + 8, bRect.top() + 10, 20, 20), aIcon);
// Label
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(isActive ? QColor(255, 235, 90) : QColor(210, 225, 245));
painter.drawText(QRect(bRect.left() + 32, bRect.top(), bRect.width() - 36, bRect.height()), Qt::AlignLeft | Qt::AlignVCenter, label);
m_activeActionBubbles.push_back({bRect, act, tip});
}
}
void QSceneRenderer::drawCityBorders(QPainter &painter, const QMatrix4x4 &vMatrix)
{
painter.setRenderHint(QPainter::Antialiasing, true);
City *activeCity = GameManager::instance().getActiveCityView();
for (const auto &city : GameManager::instance().getCities()) {
if (activeCity && city.id != activeCity->id) continue;
const Player *player = GameManager::instance().players().getPlayer(city.playerId);
QColor ribbonColor = player ? player->primaryColor : QColor(255, 215, 60);
for (const auto &t : city.territory) {
int tx = t.first;
int ty = t.second;
QVector2D center = Pathfinder::hexToWorld(tx, ty);
for (int e = 0; e < 6; ++e) {
int nx, ny;
getHexNeighbor(tx, ty, e, nx, ny);
if (!city.ownsTile(nx, ny)) {
QVector2D p1, p2;
getHexEdgeEndpoints(e, p1, p2);
float wx1 = center.x() + p1.x();
float wy1 = center.y() + p1.y();
float wz1 = Unit::getTerrainHeight(wx1, wy1, m_point, m_numPoints05) + 0.035f;
float wx2 = center.x() + p2.x();
float wy2 = center.y() + p2.y();
float wz2 = Unit::getTerrainHeight(wx2, wy2, m_point, m_numPoints05) + 0.035f;
bool inFront1 = false, inFront2 = false;
QPointF s1 = UnitRenderer::projectToScreen(QVector3D(wx1, wy1, wz1), Projection, vMatrix, width(), height(), inFront1);
QPointF s2 = UnitRenderer::projectToScreen(QVector3D(wx2, wy2, wz2), Projection, vMatrix, width(), height(), inFront2);
if (inFront1 && inFront2) {
QColor glow = ribbonColor;
glow.setAlpha(90);
painter.setPen(QPen(glow, 5.5f, Qt::SolidLine, Qt::RoundCap));
painter.drawLine(s1, s2);
painter.setPen(QPen(ribbonColor, 2.6f, Qt::SolidLine, Qt::RoundCap));
painter.drawLine(s1, s2);
}
}
}
}
}
}
void QSceneRenderer::drawRoadsOverlay(QPainter &painter, const QMatrix4x4 &vMatrix)
{
painter.setRenderHint(QPainter::Antialiasing, true);
const auto &roads = GameManager::instance().getTileRoads();
if (roads.empty()) return;
RoadConfigManager &roadConfigs = GameManager::instance().roadConfigs();
float meanderAmp = roadConfigs.meanderAmplitude();
int subdivisions = std::max(2, roadConfigs.meanderSubdivisions());
struct RoadBatch {
std::vector<QPolygonF> polylines;
std::vector<QPointF> centerJunctions;
std::vector<QPointF> isolatedCenters;
};
std::unordered_map<std::string, RoadBatch> batches;
for (const auto &pair : roads) {
const TileRoad &road = pair.second;
QVector2D center = Pathfinder::hexToWorld(road.tileX, road.tileY);
float centerZ = Unit::getTerrainHeight(center.x(), center.y(), m_point, m_numPoints05) + 0.028f;
bool cInFront = false;
QPointF cScreen = UnitRenderer::projectToScreen(QVector3D(center.x(), center.y(), centerZ), Projection, vMatrix, width(), height(), cInFront);
RoadBatch &batch = batches[road.roadTypeId];
if (road.connectionMask == 0) {
if (cInFront) {
batch.isolatedCenters.push_back(cScreen);
}
continue;
}
if (cInFront) {
batch.centerJunctions.push_back(cScreen);
}
for (int e = 0; e < 6; ++e) {
if (!road.hasConnection(e)) continue;
QVector2D endP = center + getHexEdgeMidpoint(e);
QVector2D dir = endP - center;
float len = dir.length();
QVector2D perp = (len > 0.0001f) ? QVector2D(-dir.y() / len, dir.x() / len) : QVector2D(0, 0);
// Deterministic pseudo-random seed for subtle, natural meandering
float seed = std::sin(float(road.tileX * 41 + road.tileY * 113 + e * 29)) * 43758.5453f;
float meanderSign = (seed - std::floor(seed) > 0.5f) ? 1.0f : -1.0f;
QPolygonF poly;
bool allVisible = true;
for (int i = 0; i <= subdivisions; ++i) {
float t = float(i) / float(subdivisions);
// Zero offset at both endpoints (t=0 and t=1) ensures perfect tile-to-tile alignment
float offset = std::sin(2.0f * 3.14159265f * t) * meanderAmp * meanderSign;
QVector2D p2d = center + dir * t + perp * offset;
float wz = Unit::getTerrainHeight(p2d.x(), p2d.y(), m_point, m_numPoints05) + 0.028f;
bool inFront = false;
QPointF sPos = UnitRenderer::projectToScreen(QVector3D(p2d.x(), p2d.y(), wz), Projection, vMatrix, width(), height(), inFront);
if (!inFront) {
allVisible = false;
break;
}
poly.append(sPos);
}
if (allVisible && poly.size() >= 2) {
batch.polylines.push_back(poly);
}
}
}
for (const auto &bPair : batches) {
const std::string &typeId = bPair.first;
const RoadBatch &batch = bPair.second;
const RoadTypeConfig *rCfg = roadConfigs.getRoadType(typeId);
QColor casingCol = rCfg ? rCfg->getBorderColor() : QColor(92, 64, 40);
float casingWidth = rCfg ? rCfg->borderWidth : 4.5f;
QColor surfaceCol = rCfg ? rCfg->getColor() : QColor(194, 166, 117);
float surfaceWidth = rCfg ? rCfg->width : 2.8f;
// Pass 1: Outer Casing / Under-layer
painter.setPen(QPen(casingCol, casingWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
for (const auto &poly : batch.polylines) {
painter.drawPolyline(poly);
}
// Pass 2: Road Surface Core
painter.setPen(QPen(surfaceCol, surfaceWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
for (const auto &poly : batch.polylines) {
painter.drawPolyline(poly);
}
// Pass 3: Center junction smooth cap
painter.setPen(Qt::NoPen);
painter.setBrush(surfaceCol);
for (const auto &cP : batch.centerJunctions) {
painter.drawEllipse(cP, surfaceWidth * 0.5f, surfaceWidth * 0.5f);
}
// Isolated road markers (tile has road, no connected neighbor yet)
for (const auto &cP : batch.isolatedCenters) {
painter.setBrush(surfaceCol);
painter.setPen(QPen(casingCol, 1.5f));
painter.drawEllipse(cP, surfaceWidth * 0.9f, surfaceWidth * 0.9f);
}
}
}
void QSceneRenderer::drawImprovementsOverlay(QPainter &painter, const QMatrix4x4 &vMatrix)
{
painter.setRenderHint(QPainter::Antialiasing, true);
const auto &improvements = GameManager::instance().getTileImprovements();
if (improvements.empty()) return;
for (const auto &pair : improvements) {
const TileImprovement &imp = pair.second;
bool inFront = false;
QPointF screenP = UnitRenderer::projectToScreen(imp.worldPos + QVector3D(0, 0, 0.20f), Projection, vMatrix, width(), height(), inFront);
if (!inFront) continue;
// Draw styled badge for improvement (Farm)
float r = 14.0f;
QRect badgeRect((int)(screenP.x() - r), (int)(screenP.y() - r), (int)(r * 2), (int)(r * 2));
// Background disc
painter.setBrush(QColor(24, 30, 20, 235));
painter.setPen(QPen(QColor(235, 195, 60), 1.6));
painter.drawEllipse(badgeRect);
// Icon inside disc
QImage farmIcon = getTintedIcon("images/actions/farm.png", QColor(255, 220, 75));
painter.drawImage(QRectF(screenP.x() - 8, screenP.y() - 8, 16, 16), farmIcon);
// Label pill under badge
QRect labelRect((int)screenP.x() - 26, (int)screenP.y() + 13, 52, 13);
painter.setBrush(QColor(15, 20, 28, 220));
painter.setPen(QPen(QColor(200, 165, 50, 150), 1.0));
painter.drawRoundedRect(labelRect, 3, 3);
painter.setFont(QFont("sans-serif", 6, QFont::Bold));
painter.setPen(QColor(255, 230, 130));
painter.drawText(labelRect, Qt::AlignCenter, Localization::instance().get("ui.improvement.farm", "Farm"));
}
}
void QSceneRenderer::drawCityViewOverlay(QPainter &painter, const QMatrix4x4 &vMatrix)
{
m_citySlotHits.clear();
m_cityBuyTileHits.clear();
m_cityProjectHits.clear();
m_cancelProjectBtnRect = QRect();
m_tabBuildingsRect = QRect();
m_tabUnitsRect = QRect();
City *city = GameManager::instance().getActiveCityView();
if (!city) return;
painter.setRenderHint(QPainter::Antialiasing, true);
const Player *player = GameManager::instance().players().getPlayer(city->playerId);
QColor primaryCol = player ? player->primaryColor : Qt::blue;
QColor secondaryCol = player ? player->secondaryColor : Qt::white;
int maxRadius = TerrainYieldManager::instance().getCityConfig().maxWorkableRadius; // 3 deep hexagons
// 1. Render all hexes up to 3 distance around the city
for (int dx = -maxRadius - 2; dx <= maxRadius + 2; ++dx) {
for (int dy = -maxRadius - 2; dy <= maxRadius + 2; ++dy) {
int hx = city->tileX + dx;
int hy = city->tileY + dy;
if (hx < 0 || hx >= MAP_WIDTH || hy < 0 || hy >= MAP_HEIGHT) continue;
int dist = Pathfinder::hexDistance(city->tileX, city->tileY, hx, hy);
if (dist > maxRadius) continue;
QVector2D center = Pathfinder::hexToWorld(hx, hy);
float gz = Unit::getTerrainHeight(center.x(), center.y(), m_point, m_numPoints05);
bool inFront = false;
QPointF sCenter = UnitRenderer::projectToScreen(QVector3D(center.x(), center.y(), gz + 0.05f), Projection, vMatrix, width(), height(), inFront);
if (!inFront) continue;
bool isOwned = city->ownsTile(hx, hy);
// Draw hexagon boundary outline
QPolygonF hexPoly;
bool polyValid = true;
for (int e = 0; e < 6; ++e) {
QVector2D p1, p2;
getHexEdgeEndpoints(e, p1, p2);
float px = center.x() + p1.x();
float py = center.y() + p1.y();
float pz = Unit::getTerrainHeight(px, py, m_point, m_numPoints05) + 0.02f;
bool pInFront = false;
QPointF sp = UnitRenderer::projectToScreen(QVector3D(px, py, pz), Projection, vMatrix, width(), height(), pInFront);
if (!pInFront) { polyValid = false; break; }
hexPoly << sp;
}
if (polyValid) {
if (isOwned) {
painter.setPen(QPen(QColor(80, 200, 255, 140), 1.4, Qt::SolidLine));
painter.setBrush(QColor(40, 120, 200, 30));
} else {
painter.setPen(QPen(QColor(120, 130, 145, 90), 1.0, Qt::DashLine));
painter.setBrush(QColor(20, 25, 35, 40));
}
painter.drawPolygon(hexPoly);
}
// Check workability
QString tType = TerrainYieldManager::instance().getTerrainType(hx, hy, m_point, m_numPoints05);
const TerrainYieldConfig *yCfg = TerrainYieldManager::instance().getYieldConfig(tType);
if (isOwned) {
if (yCfg && yCfg->workable) {
// Tile has 1 slot!
bool isWorked = city->isTileWorked(hx, hy);
QString bestRes = GameManager::instance().getBestTileResource(hx, hy, m_point, m_numPoints05);
QString res = city->getEffectiveResource(hx, hy, bestRes);
int amount = GameManager::instance().getTileYield(hx, hy, res, m_point, m_numPoints05);
const TileImprovement *imp = GameManager::instance().getImprovementAt(hx, hy);
float slotR = 15.0f;
QRect slotRect((int)(sCenter.x() - slotR), (int)(sCenter.y() - slotR), (int)(slotR * 2), (int)(slotR * 2));
m_citySlotHits.push_back({slotRect, hx, hy, res});
// Slot circle background
painter.setBrush(isWorked ? QColor(25, 55, 38, 245) : QColor(18, 25, 36, 225));
painter.setPen(QPen(isWorked ? QColor(80, 240, 120) : QColor(140, 175, 215), isWorked ? 2.2 : 1.2));
painter.drawEllipse(sCenter, slotR, slotR);
// Long-press charging progress ring on the active held slot
if (m_slotPressPending && m_pressedSlotTx == hx && m_pressedSlotTy == hy) {
int dur = TerrainYieldManager::instance().getCityConfig().slotLongPressDurationMs;
float charge = std::min(1.0f, (float)m_slotPressElapsed.elapsed() / (float)std::max(1, dur));
QRectF chargeRect(sCenter.x() - (slotR + 3.0f), sCenter.y() - (slotR + 3.0f), (slotR + 3.0f) * 2, (slotR + 3.0f) * 2);
painter.setPen(QPen(QColor(255, 220, 80, 230), 2.5));
painter.setBrush(Qt::NoBrush);
painter.drawArc(chargeRect, 90 * 16, -(int)(charge * 360 * 16));
}
// Active menu highlight ring
if (m_resourceMenuOpen && m_resourceMenuTx == hx && m_resourceMenuTy == hy) {
QRectF activeMenuRect(sCenter.x() - (slotR + 3.0f), sCenter.y() - (slotR + 3.0f), (slotR + 3.0f) * 2, (slotR + 3.0f) * 2);
painter.setPen(QPen(QColor(80, 200, 255, 240), 2.0));
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(activeMenuRect);
}
// Resource icon
QImage rIcon = getTintedIcon("images/resources/" + res + ".png", isWorked ? QColor(80, 255, 120) : QColor(180, 210, 245));
painter.drawImage(QRectF(sCenter.x() - 8.5, sCenter.y() - 8.5, 17, 17), rIcon);
// If tile has farm/improvement, draw small icon on upper right of slot
if (imp) {
QImage fIcon = getTintedIcon("images/actions/farm.png", QColor(255, 215, 60));
painter.drawImage(QRectF(sCenter.x() + 8, sCenter.y() - 16, 14, 14), fIcon);
}
// Yield pill below slot
QRect pillRect((int)sCenter.x() - 26, (int)sCenter.y() + 16, 52, 14);
painter.setBrush(QColor(15, 20, 28, 220));
painter.setPen(QPen(isWorked ? QColor(80, 240, 120, 160) : QColor(80, 110, 140, 120), 1.0));
painter.drawRoundedRect(pillRect, 3, 3);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(isWorked ? QColor(100, 255, 140) : QColor(180, 205, 235));
painter.drawText(pillRect, Qt::AlignCenter, QString("+%1").arg(amount));
// If unworked and pop is available: show small green +
if (!isWorked && city->getAvailablePop() > 0) {
float pulse = (std::sin(m_elapsedTimer.elapsed() * 0.008f) + 1.0f) * 0.5f;
painter.setBrush(QColor(60, 220, 90, (int)(180 + pulse * 75)));
painter.setPen(Qt::NoPen);
painter.drawEllipse(QPointF(sCenter.x() + 10, sCenter.y() - 10), 4.5, 4.5);
}
}
// If not workable (e.g. Sneeuw / Snow): no slot is shown, as requested!
} else {
int otherCityId = -1;
bool isOwnedByOther = GameManager::instance().isTileOwned(hx, hy, &otherCityId);
if (!isOwnedByOther) {
// Unowned tile: Can be bought with culture!
int cultureCost = TerrainYieldManager::instance().getCityConfig().tileCultureCost;
bool canAfford = (player && player->culture >= cultureCost);
QRect buyRect((int)sCenter.x() - 34, (int)sCenter.y() - 14, 68, 28);
m_cityBuyTileHits.push_back({buyRect, hx, hy});
// Background & border
painter.setBrush(canAfford ? QColor(40, 20, 55, 235) : QColor(24, 18, 30, 215));
painter.setPen(QPen(canAfford ? QColor(220, 110, 255) : QColor(120, 80, 140), canAfford ? 1.8 : 1.0));
painter.drawRoundedRect(buyRect, 5, 5);
// Culture icon
QImage cultIcon = getTintedIcon("images/resources/cultuur.png", canAfford ? QColor(225, 120, 255) : QColor(150, 110, 170));
painter.drawImage(QRect(buyRect.left() + 4, buyRect.top() + 6, 16, 16), cultIcon);
// Price text
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(canAfford ? QColor(255, 230, 255) : QColor(165, 145, 180));
QString costStr = QString("%L1").arg(cultureCost);
painter.drawText(QRect(buyRect.left() + 22, buyRect.top() + 2, buyRect.width() - 24, 13), Qt::AlignLeft | Qt::AlignVCenter, costStr);
// "Buy" subtitle
painter.setFont(QFont("sans-serif", 6, QFont::Bold));
painter.setPen(canAfford ? QColor(220, 140, 255) : QColor(140, 120, 155));
painter.drawText(QRect(buyRect.left() + 22, buyRect.top() + 14, buyRect.width() - 24, 12),
Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.city.buy_tile", "BUY"));
} else {
// Owned by another city
QRect lockRect((int)sCenter.x() - 16, (int)sCenter.y() - 8, 32, 16);
painter.setBrush(QColor(40, 20, 20, 190));
painter.setPen(QPen(QColor(160, 60, 60, 140), 1.0));
painter.drawRoundedRect(lockRect, 3, 3);
painter.setFont(QFont("sans-serif", 6, QFont::Bold));
painter.setPen(QColor(230, 130, 130));
painter.drawText(lockRect, Qt::AlignCenter,
Localization::instance().get("ui.city.occupied", "OCCUPIED"));
}
}
}
}
// 2. City Management Top Card
int cardW = 600, cardH = 174;
QRect cardRect((width() - cardW) / 2, 14, cardW, cardH);
painter.setBrush(QColor(16, 22, 32, 245));
painter.setPen(QPen(primaryCol, 1.8));
painter.drawRoundedRect(cardRect, 10, 10);
// City Title & Close Button
painter.setFont(QFont("sans-serif", 13, QFont::Bold));
painter.setPen(QColor(245, 250, 255));
QString titleText = Localization::instance().get("ui.city.management_title", "%1 - CITY MANAGEMENT").arg(city->name.toUpper());
painter.drawText(QRect(cardRect.left() + 16, cardRect.top() + 8, 380, 24), Qt::AlignLeft | Qt::AlignVCenter, titleText);
// Close button [X]
m_closeCityViewBtnRect = QRect(cardRect.right() - 88, cardRect.top() + 8, 74, 24);
painter.setBrush(QColor(40, 50, 68, 220));
painter.setPen(QPen(secondaryCol, 1.2));
painter.drawRoundedRect(m_closeCityViewBtnRect, 4, 4);
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(220, 235, 255));
painter.drawText(m_closeCityViewBtnRect, Qt::AlignCenter, Localization::instance().get("ui.city.close_btn", "[X] Close"));
// Row 1: Population & Local Resources (Food & Labor are local to the city)
int totalPop = city->getTotalPop();
int assigned = city->getAssignedPop();
int avail = city->getAvailablePop();
painter.setFont(QFont("sans-serif", 8));
painter.setPen(QColor(180, 205, 235));
QString popInfo = Localization::instance().get("ui.city.local_info", "Local: Population: %L1 (%2 Pop: %3 working, %4 free) | Labor: %L5")
.arg(city->inhabitants).arg(totalPop).arg(assigned).arg(avail).arg(city->accumulatedLabor);
painter.drawText(QRect(cardRect.left() + 16, cardRect.top() + 34, cardRect.width() - 32, 16), Qt::AlignLeft | Qt::AlignVCenter, popInfo);
// Row 2: National Resources (Culture, Science, Gold, Faith belong to the nation)
int cult = player ? player->culture : 0;
int sci = player ? player->science : 0;
int gld = player ? player->gold : 0;
int fth = player ? player->faith : 0;
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(220, 160, 255));
QString natInfo = Localization::instance().get("ui.city.national_info", "National: Culture: %L1 | Science: %L2 | Gold: %L3 | Faith: %L4")
.arg(cult).arg(sci).arg(gld).arg(fth);
painter.drawText(QRect(cardRect.left() + 16, cardRect.top() + 52, cardRect.width() - 32, 16), Qt::AlignLeft | Qt::AlignVCenter, natInfo);
// Separator line
painter.setPen(QPen(QColor(60, 85, 120, 150), 1.0));
painter.drawLine(cardRect.left() + 16, cardRect.top() + 72, cardRect.right() - 16, cardRect.top() + 72);
// Row 3: Total Yields Summary Row (Base 500 + worked tiles + buildings)
CityYieldSummary summary = GameManager::instance().calculateCityYields(*city, m_point, m_numPoints05);
struct YieldItem { QString name; int val; QString icon; QColor col; };
std::vector<YieldItem> yields = {
{"Food", summary.food, "images/resources/voedsel.png", QColor(100, 230, 110)},
{"Labor", summary.labor, "images/resources/arbeid.png", QColor(240, 140, 50)},
{"Science", summary.science, "images/resources/kennis.png", QColor(60, 190, 255)},
{"Culture", summary.culture, "images/resources/cultuur.png", QColor(200, 110, 240)},
{"Faith", summary.faith, "images/resources/geloof.png", QColor(255, 230, 80)},
{"Gold", summary.gold, "images/resources/goud.png", QColor(255, 210, 50)}
};
int colW = (cardRect.width() - 32) / 6;
for (size_t i = 0; i < yields.size(); ++i) {
int cx = cardRect.left() + 16 + (int)i * colW;
QRect yRect(cx, cardRect.top() + 78, colW - 6, 42);
painter.setBrush(QColor(12, 16, 24, 180));
painter.setPen(QPen(QColor(40, 60, 85, 120), 1.0));
painter.drawRoundedRect(yRect, 4, 4);
QImage icon = getTintedIcon(yields[i].icon, yields[i].col);
painter.drawImage(QRect(yRect.left() + 6, yRect.top() + 5, 16, 16), icon);
painter.setFont(QFont("sans-serif", 7));
painter.setPen(QColor(160, 180, 205));
painter.drawText(QRect(yRect.left() + 26, yRect.top() + 5, yRect.width() - 28, 16), Qt::AlignLeft | Qt::AlignVCenter, yields[i].name);
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(yields[i].col);
painter.drawText(QRect(yRect.left() + 6, yRect.top() + 22, yRect.width() - 12, 16), Qt::AlignLeft | Qt::AlignVCenter, QString("+%1/tick").arg(yields[i].val));
}
// Help instruction at bottom of top card
painter.setFont(QFont("sans-serif", 7, QFont::StyleItalic));
painter.setPen(QColor(140, 170, 200));
painter.drawText(QRect(cardRect.left() + 16, cardRect.bottom() - 18, cardRect.width() - 32, 16), Qt::AlignCenter,
Localization::instance().get("ui.city.help_text", "Click a tile to assign 1 Pop | Click an unowned tile to buy with Culture."));
// 3. City Production & Districts Bottom Panel
int panelW = std::min(980, width() - 40);
int panelH = 195;
QRect prodPanelRect((width() - panelW) / 2, height() - panelH - 14, panelW, panelH);
painter.setBrush(QColor(16, 22, 32, 248));
painter.setPen(QPen(primaryCol, 1.8));
painter.drawRoundedRect(prodPanelRect, 10, 10);
// Left Column: Active Production & District (width 340)
int leftColW = 340;
QRect leftColRect(prodPanelRect.left() + 14, prodPanelRect.top() + 10, leftColW, panelH - 20);
// Header: Current Production
painter.setFont(QFont("sans-serif", 9, QFont::Bold));
painter.setPen(QColor(230, 240, 255));
painter.drawText(QRect(leftColRect.left(), leftColRect.top(), leftColRect.width(), 18),
Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.city.current_production", "CURRENT PRODUCTION"));
if (city->hasActiveProject()) {
const CityProject &proj = city->currentProject;
float progress = proj.getProgress();
int pct = (int)(progress * 100.0f);
int remainingLabor = proj.getRemainingLabor();
int laborRate = std::max(1, summary.labor);
int estTicks = (int)std::ceil((float)remainingLabor / (float)laborRate);
// Project Icon & Name
QString projIconPath = "images/improvements/granary.png";
if (proj.type == CityProjectType::Unit) {
const UnitConfig *uCfg = GameManager::instance().unitConfigs().getConfig(proj.id);
if (uCfg && !uCfg->iconPath.isEmpty()) projIconPath = uCfg->iconPath;
} else if (proj.type == CityProjectType::Building) {
const BuildingConfig *bCfg = GameManager::instance().buildingConfigs().getBuilding(proj.id);
if (bCfg && !bCfg->iconPath.isEmpty()) projIconPath = bCfg->iconPath;
}
QImage pIcon = getTintedIcon(projIconPath, QColor(255, 215, 60));
painter.drawImage(QRect(leftColRect.left(), leftColRect.top() + 24, 28, 28), pIcon);
painter.setFont(QFont("sans-serif", 10, QFont::Bold));
painter.setPen(QColor(255, 235, 120));
painter.drawText(QRect(leftColRect.left() + 34, leftColRect.top() + 22, 220, 18),
Qt::AlignLeft | Qt::AlignVCenter, proj.name);
painter.setFont(QFont("sans-serif", 7));
painter.setPen(QColor(170, 195, 220));
QString typeDesc = (proj.type == CityProjectType::Unit) ? "Unit" : QString("Building (%1)").arg(proj.district);
painter.drawText(QRect(leftColRect.left() + 34, leftColRect.top() + 38, 220, 14),
Qt::AlignLeft | Qt::AlignVCenter, typeDesc);
// Cancel button
m_cancelProjectBtnRect = QRect(leftColRect.right() - 65, leftColRect.top() + 24, 65, 22);
painter.setBrush(QColor(50, 25, 30, 220));
painter.setPen(QPen(QColor(220, 90, 90), 1.0));
painter.drawRoundedRect(m_cancelProjectBtnRect, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(255, 150, 150));
painter.drawText(m_cancelProjectBtnRect, Qt::AlignCenter,
Localization::instance().get("ui.city.cancel_btn", "Cancel"));
// Progress Bar
QRect barBg(leftColRect.left(), leftColRect.top() + 56, leftColRect.width(), 16);
painter.setBrush(QColor(25, 35, 48));
painter.setPen(QPen(QColor(60, 85, 120), 1.0));
painter.drawRoundedRect(barBg, 3, 3);
QRect barFg(leftColRect.left(), leftColRect.top() + 56, (int)(leftColRect.width() * progress), 16);
painter.setBrush(QColor(240, 145, 40));
painter.setPen(Qt::NoPen);
painter.drawRoundedRect(barFg, 3, 3);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(Qt::white);
QString progText = QString("%1% (%L2 / %L3 Labor)").arg(pct).arg(proj.currentLabor).arg(proj.laborCost);
painter.drawText(barBg, Qt::AlignCenter, progText);
// Subtitle: Estimated ticks remaining
painter.setFont(QFont("sans-serif", 7));
painter.setPen(QColor(160, 185, 215));
painter.drawText(QRect(leftColRect.left(), leftColRect.top() + 74, leftColRect.width(), 14),
Qt::AlignLeft | Qt::AlignVCenter,
QString("~%1 %2 remaining (+%3 Labor/tick)")
.arg(estTicks).arg(estTicks == 1 ? "tick" : "ticks").arg(laborRate));
} else {
// No project active
painter.setFont(QFont("sans-serif", 9, QFont::StyleItalic));
painter.setPen(QColor(180, 195, 210));
painter.drawText(QRect(leftColRect.left(), leftColRect.top() + 28, leftColRect.width(), 20),
Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.city.no_project", "No active project"));
painter.setFont(QFont("sans-serif", 7));
painter.setPen(QColor(140, 160, 180));
painter.drawText(QRect(leftColRect.left(), leftColRect.top() + 50, leftColRect.width(), 30),
Qt::AlignLeft | Qt::TextWordWrap,
Localization::instance().get("ui.city.select_prompt", "Select a unit or building to construct"));
}
// Separator between project and districts
painter.setPen(QPen(QColor(60, 85, 120, 150), 1.0));
painter.drawLine(leftColRect.left(), leftColRect.top() + 94, leftColRect.right(), leftColRect.top() + 94);
// District Section: Centrum / City Center
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(220, 200, 140));
painter.drawText(QRect(leftColRect.left(), leftColRect.top() + 100, leftColRect.width(), 16),
Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.city.districts_header", "DISTRICT: CITY CENTER"));
// Completed buildings inside this district
int bCount = 0;
int bOffsetY = leftColRect.top() + 120;
for (const QString &bId : city->completedBuildings) {
const BuildingConfig *bCfg = GameManager::instance().buildingConfigs().getBuilding(bId);
if (!bCfg) continue;
QRect bBadge(leftColRect.left() + bCount * 168, bOffsetY, 162, 46);
painter.setBrush(QColor(25, 38, 30, 220));
painter.setPen(QPen(QColor(80, 220, 120), 1.2));
painter.drawRoundedRect(bBadge, 4, 4);
QImage bIcon = getTintedIcon(bCfg->iconPath.isEmpty() ? "images/improvements/granary.png" : bCfg->iconPath, QColor(80, 240, 120));
painter.drawImage(QRect(bBadge.left() + 6, bBadge.top() + 6, 20, 20), bIcon);
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(210, 255, 220));
painter.drawText(QRect(bBadge.left() + 30, bBadge.top() + 4, bBadge.width() - 34, 16),
Qt::AlignLeft | Qt::AlignVCenter, bCfg->name);
painter.setFont(QFont("sans-serif", 7));
painter.setPen(QColor(160, 230, 180));
painter.drawText(QRect(bBadge.left() + 6, bBadge.top() + 24, bBadge.width() - 10, 16),
Qt::AlignLeft | Qt::AlignVCenter, bCfg->description);
bCount++;
if (bCount >= 2) break;
}
if (city->completedBuildings.empty()) {
painter.setFont(QFont("sans-serif", 7, QFont::StyleItalic));
painter.setPen(QColor(140, 160, 180));
painter.drawText(QRect(leftColRect.left(), bOffsetY + 8, leftColRect.width(), 16),
Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.city.no_buildings", "No extensions built yet"));
}
// Vertical divider line between left and right columns
int divX = leftColRect.right() + 14;
painter.setPen(QPen(QColor(60, 85, 120, 150), 1.0));
painter.drawLine(divX, prodPanelRect.top() + 10, divX, prodPanelRect.bottom() - 10);
// Right Column: Project Selection (Tabs + Items)
int rightColX = divX + 14;
int rightColW = prodPanelRect.right() - rightColX - 14;
// Header & Tabs
painter.setFont(QFont("sans-serif", 9, QFont::Bold));
painter.setPen(QColor(230, 240, 255));
painter.drawText(QRect(rightColX, prodPanelRect.top() + 10, 160, 20),
Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.city.choose_project", "CHOOSE CITY PROJECT"));
// Tabs: [Buildings & Extensions] and [Units]
int tabW = 155, tabH = 22;
m_tabBuildingsRect = QRect(rightColX + 170, prodPanelRect.top() + 9, tabW, tabH);
m_tabUnitsRect = QRect(m_tabBuildingsRect.right() + 8, prodPanelRect.top() + 9, 90, tabH);
// Draw Tab Buildings
bool isBTab = (m_cityProjectTab == 0);
painter.setBrush(isBTab ? QColor(45, 75, 120, 240) : QColor(25, 35, 50, 180));
painter.setPen(QPen(isBTab ? QColor(100, 190, 255) : QColor(60, 85, 120), isBTab ? 1.5 : 1.0));
painter.drawRoundedRect(m_tabBuildingsRect, 4, 4);
painter.setFont(QFont("sans-serif", 8, isBTab ? QFont::Bold : QFont::Normal));
painter.setPen(isBTab ? Qt::white : QColor(160, 185, 215));
painter.drawText(m_tabBuildingsRect, Qt::AlignCenter,
Localization::instance().get("ui.city.tab_buildings", "Buildings & Extensions"));
// Draw Tab Units
bool isUTab = (m_cityProjectTab == 1);
painter.setBrush(isUTab ? QColor(45, 75, 120, 240) : QColor(25, 35, 50, 180));
painter.setPen(QPen(isUTab ? QColor(100, 190, 255) : QColor(60, 85, 120), isUTab ? 1.5 : 1.0));
painter.drawRoundedRect(m_tabUnitsRect, 4, 4);
painter.setFont(QFont("sans-serif", 8, isUTab ? QFont::Bold : QFont::Normal));
painter.setPen(isUTab ? Qt::white : QColor(160, 185, 215));
painter.drawText(m_tabUnitsRect, Qt::AlignCenter,
Localization::instance().get("ui.city.tab_units", "Units"));
// Render Items under active tab
int listY = prodPanelRect.top() + 38;
int itemH = 65;
if (m_cityProjectTab == 0) {
// Buildings tab
const auto &buildings = GameManager::instance().buildingConfigs().getAllBuildings();
int idx = 0;
int colItemW = (rightColW - 10) / 2;
for (auto it = buildings.begin(); it != buildings.end(); ++it) {
const BuildingConfig &bCfg = it.value();
int ix = rightColX + (idx % 2) * (colItemW + 10);
int iy = listY + (idx / 2) * (itemH + 6);
QRect itemRect(ix, iy, colItemW, itemH);
bool alreadyBuilt = city->hasBuilding(bCfg.id);
bool isActiveProj = (city->hasActiveProject() && city->currentProject.type == CityProjectType::Building && city->currentProject.id == bCfg.id);
painter.setBrush(isActiveProj ? QColor(35, 50, 75, 230) : (alreadyBuilt ? QColor(20, 25, 32, 190) : QColor(24, 32, 46, 220)));
painter.setPen(QPen(isActiveProj ? QColor(255, 220, 50) : (alreadyBuilt ? QColor(60, 75, 95) : QColor(70, 100, 140)), isActiveProj ? 1.8 : 1.0));
painter.drawRoundedRect(itemRect, 6, 6);
// Icon
QImage bIcon = getTintedIcon(bCfg.iconPath.isEmpty() ? "images/improvements/granary.png" : bCfg.iconPath,
alreadyBuilt ? QColor(120, 140, 160) : QColor(255, 215, 60));
painter.drawImage(QRect(itemRect.left() + 8, itemRect.top() + 8, 28, 28), bIcon);
// Building Name
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(alreadyBuilt ? QColor(150, 165, 180) : QColor(245, 250, 255));
painter.drawText(QRect(itemRect.left() + 42, itemRect.top() + 6, itemRect.width() - 110, 16),
Qt::AlignLeft | Qt::AlignVCenter, bCfg.name);
// District & Cost
int estTicks = (int)std::ceil((float)bCfg.laborCost / (float)std::max(1, summary.labor));
painter.setFont(QFont("sans-serif", 7));
painter.setPen(QColor(240, 160, 50));
QString costStr = QString("%L1 Labor (%2 %3)").arg(bCfg.laborCost).arg(estTicks).arg(estTicks == 1 ? "tick" : "ticks");
painter.drawText(QRect(itemRect.left() + 42, itemRect.top() + 22, itemRect.width() - 110, 14),
Qt::AlignLeft | Qt::AlignVCenter, costStr);
// Description / Effect
painter.setFont(QFont("sans-serif", 7));
painter.setPen(alreadyBuilt ? QColor(120, 180, 140) : QColor(100, 240, 130));
painter.drawText(QRect(itemRect.left() + 42, itemRect.top() + 38, itemRect.width() - 50, 16),
Qt::AlignLeft | Qt::AlignVCenter, bCfg.description);
// Action Button / Status badge on right side
QRect btnRect(itemRect.right() - 66, itemRect.top() + 10, 58, 24);
if (alreadyBuilt) {
painter.setBrush(QColor(30, 40, 35, 180));
painter.setPen(QPen(QColor(70, 120, 85), 1.0));
painter.drawRoundedRect(btnRect, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(120, 180, 130));
painter.drawText(btnRect, Qt::AlignCenter,
Localization::instance().get("ui.city.status_built", "BUILT"));
} else if (isActiveProj) {
painter.setBrush(QColor(60, 50, 20, 220));
painter.setPen(QPen(QColor(255, 215, 50), 1.2));
painter.drawRoundedRect(btnRect, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(255, 230, 80));
painter.drawText(btnRect, Qt::AlignCenter,
Localization::instance().get("ui.city.status_in_progress", "ACTIVE"));
} else {
painter.setBrush(QColor(30, 60, 100, 240));
painter.setPen(QPen(QColor(80, 160, 255), 1.2));
painter.drawRoundedRect(btnRect, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(Qt::white);
painter.drawText(btnRect, Qt::AlignCenter,
Localization::instance().get("ui.city.btn_build", "Build"));
m_cityProjectHits.push_back({btnRect, CityProjectType::Building, bCfg.id});
}
idx++;
}
} else {
// Units tab
const auto &units = GameManager::instance().unitConfigs().getAllConfigs();
int idx = 0;
int colItemW = (rightColW - 10) / 2;
for (auto it = units.begin(); it != units.end(); ++it) {
const UnitConfig &uCfg = it.value();
int ix = rightColX + (idx % 2) * (colItemW + 10);
int iy = listY + (idx / 2) * (itemH + 6);
QRect itemRect(ix, iy, colItemW, itemH);
bool isActiveProj = (city->hasActiveProject() && city->currentProject.type == CityProjectType::Unit && city->currentProject.id == uCfg.id);
painter.setBrush(isActiveProj ? QColor(35, 50, 75, 230) : QColor(24, 32, 46, 220));
painter.setPen(QPen(isActiveProj ? QColor(255, 220, 50) : QColor(70, 100, 140), isActiveProj ? 1.8 : 1.0));
painter.drawRoundedRect(itemRect, 6, 6);
// Icon
QImage uIcon = getTintedIcon(uCfg.iconPath, primaryCol);
painter.drawImage(QRect(itemRect.left() + 8, itemRect.top() + 8, 28, 28), uIcon);
// Unit Name
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(245, 250, 255));
painter.drawText(QRect(itemRect.left() + 42, itemRect.top() + 6, itemRect.width() - 110, 16),
Qt::AlignLeft | Qt::AlignVCenter, uCfg.name);
// Cost & Estimated Ticks
int estTicks = (int)std::ceil((float)uCfg.laborCost / (float)std::max(1, summary.labor));
painter.setFont(QFont("sans-serif", 7));
painter.setPen(QColor(240, 160, 50));
QString costStr = QString("%L1 Labor (%2 %3)").arg(uCfg.laborCost).arg(estTicks).arg(estTicks == 1 ? "tick" : "ticks");
painter.drawText(QRect(itemRect.left() + 42, itemRect.top() + 22, itemRect.width() - 110, 14),
Qt::AlignLeft | Qt::AlignVCenter, costStr);
// Stats info
painter.setFont(QFont("sans-serif", 7));
painter.setPen(QColor(160, 185, 215));
QString infoStr = unitCategoryToString(uCfg.category);
if (uCfg.isMilitary()) {
infoStr += QString(" | HP: %1 | Atk: %2").arg(uCfg.health).arg(uCfg.attackDamage);
}
painter.drawText(QRect(itemRect.left() + 42, itemRect.top() + 38, itemRect.width() - 50, 16),
Qt::AlignLeft | Qt::AlignVCenter, infoStr);
// Action Button / Status badge on right side
QRect btnRect(itemRect.right() - 66, itemRect.top() + 10, 58, 24);
if (isActiveProj) {
painter.setBrush(QColor(60, 50, 20, 220));
painter.setPen(QPen(QColor(255, 215, 50), 1.2));
painter.drawRoundedRect(btnRect, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(255, 230, 80));
painter.drawText(btnRect, Qt::AlignCenter,
Localization::instance().get("ui.city.status_in_progress", "ACTIVE"));
} else {
painter.setBrush(QColor(30, 60, 100, 240));
painter.setPen(QPen(QColor(80, 160, 255), 1.2));
painter.drawRoundedRect(btnRect, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(Qt::white);
painter.drawText(btnRect, Qt::AlignCenter,
Localization::instance().get("ui.city.btn_train", "Train"));
m_cityProjectHits.push_back({btnRect, CityProjectType::Unit, uCfg.id});
}
idx++;
}
}
// 4. Resource Selection Popup Menu (if open)
if (m_resourceMenuOpen) {
drawCityResourceMenu(painter, vMatrix);
}
}
void QSceneRenderer::onSlotLongPressTimeout()
{
if (m_slotPressPending && GameManager::instance().isInCityView()) {
m_slotLongPressTriggered = true;
m_slotPressPending = false;
m_resourceMenuOpen = true;
m_resourceMenuTx = m_pressedSlotTx;
m_resourceMenuTy = m_pressedSlotTy;
update();
}
}
void QSceneRenderer::drawCityResourceMenu(QPainter &painter, const QMatrix4x4 &vMatrix)
{
m_cityResourceChoiceHits.clear();
m_resourceMenuCloseBtnRect = QRect();
m_resourceMenuRect = QRect();
City *city = GameManager::instance().getActiveCityView();
if (!city) {
m_resourceMenuOpen = false;
return;
}
int tx = m_resourceMenuTx;
int ty = m_resourceMenuTy;
if (!city->ownsTile(tx, ty)) {
m_resourceMenuOpen = false;
return;
}
QVector2D center = Pathfinder::hexToWorld(tx, ty);
float gz = Unit::getTerrainHeight(center.x(), center.y(), m_point, m_numPoints05);
bool inFront = false;
QPointF sCenter = UnitRenderer::projectToScreen(QVector3D(center.x(), center.y(), gz + 0.05f), Projection, vMatrix, width(), height(), inFront);
if (!inFront) return;
QString tType = TerrainYieldManager::instance().getTerrainType(tx, ty, m_point, m_numPoints05);
const TerrainYieldConfig *yCfg = TerrainYieldManager::instance().getYieldConfig(tType);
QString tName = yCfg ? yCfg->name : tType;
const TileImprovement *imp = GameManager::instance().getImprovementAt(tx, ty);
if (imp) {
const ImprovementConfig *iCfg = TerrainYieldManager::instance().getImprovementConfig(imp->improvementId);
if (iCfg) {
tName += QString(" (%1)").arg(iCfg->name);
}
}
const CityConfig &cCfg = TerrainYieldManager::instance().getCityConfig();
QStringList resources = cCfg.resourcePriority;
if (resources.isEmpty()) {
resources = {"voedsel", "arbeid", "goud", "kennis", "cultuur", "geloof"};
}
QString bestRes = GameManager::instance().getBestTileResource(tx, ty, m_point, m_numPoints05);
QString activeRes = city->getEffectiveResource(tx, ty, bestRes);
int menuW = 240;
int rowH = 32;
int headerH = 46;
int footerH = 10;
int menuH = headerH + (resources.size() * rowH) + footerH;
// Anchor menu near sCenter
int menuX = (int)sCenter.x() + 24;
int menuY = (int)sCenter.y() - menuH / 2;
if (menuX + menuW > width() - 15) {
menuX = (int)sCenter.x() - menuW - 24;
}
if (menuX < 15) menuX = 15;
if (menuY + menuH > height() - 15) {
menuY = height() - 15 - menuH;
}
if (menuY < 15) menuY = 15;
m_resourceMenuRect = QRect(menuX, menuY, menuW, menuH);
// Connecting line from slot to menu card
painter.setPen(QPen(QColor(80, 200, 255, 120), 1.5, Qt::DashLine));
painter.drawLine(sCenter, QPointF(menuX < sCenter.x() ? menuX + menuW : menuX, menuY + menuH / 2));
// Drop shadow
painter.setBrush(QColor(0, 0, 0, 160));
painter.setPen(Qt::NoPen);
painter.drawRoundedRect(m_resourceMenuRect.adjusted(4, 4, 4, 4), 8, 8);
// Card background
painter.setBrush(QColor(16, 22, 32, 250));
painter.setPen(QPen(QColor(80, 180, 240), 1.6));
painter.drawRoundedRect(m_resourceMenuRect, 8, 8);
// Header Title
painter.setFont(QFont("sans-serif", 9, QFont::Bold));
painter.setPen(QColor(240, 248, 255));
QString title = Localization::instance().get("ui.city.select_resource", "SELECT RESOURCE");
painter.drawText(QRect(menuX + 12, menuY + 6, menuW - 40, 18), Qt::AlignLeft | Qt::AlignVCenter, title);
// Close button [X]
m_resourceMenuCloseBtnRect = QRect(menuX + menuW - 26, menuY + 6, 20, 18);
painter.setFont(QFont("sans-serif", 9, QFont::Bold));
painter.setPen(QColor(180, 190, 205));
painter.drawText(m_resourceMenuCloseBtnRect, Qt::AlignCenter, "×");
// Subtitle (tile info)
painter.setFont(QFont("sans-serif", 7));
painter.setPen(QColor(140, 170, 200));
painter.drawText(QRect(menuX + 12, menuY + 24, menuW - 24, 16), Qt::AlignLeft | Qt::AlignVCenter, tName);
// Divider line
painter.setPen(QPen(QColor(50, 75, 105), 1.0));
painter.drawLine(menuX + 10, menuY + 44, menuX + menuW - 10, menuY + 44);
// Rows
int currY = menuY + headerH;
for (const QString &res : resources) {
int yieldVal = GameManager::instance().getTileYield(tx, ty, res, m_point, m_numPoints05);
bool isActive = (res == activeRes);
bool isBest = (res == bestRes);
QRect rowRect(menuX + 8, currY, menuW - 16, rowH - 4);
m_cityResourceChoiceHits.push_back({rowRect, res, yieldVal});
bool isHovered = rowRect.contains(m_mouseCurrentPos);
// Row background
if (isActive) {
painter.setBrush(QColor(30, 65, 45, 240));
painter.setPen(QPen(QColor(80, 240, 120), 1.4));
} else if (isHovered) {
painter.setBrush(QColor(40, 60, 85, 230));
painter.setPen(QPen(QColor(100, 190, 255), 1.2));
} else {
painter.setBrush(QColor(22, 30, 42, 210));
painter.setPen(QPen(QColor(45, 60, 80), 1.0));
}
painter.drawRoundedRect(rowRect, 5, 5);
// Resource icon
QColor iconColor;
if (res == "voedsel") iconColor = QColor(90, 255, 130);
else if (res == "arbeid") iconColor = QColor(255, 160, 60);
else if (res == "goud") iconColor = QColor(255, 215, 60);
else if (res == "kennis") iconColor = QColor(80, 180, 255);
else if (res == "cultuur") iconColor = QColor(225, 110, 255);
else if (res == "geloof") iconColor = QColor(220, 230, 255);
else iconColor = QColor(200, 220, 240);
QImage resIcon = getTintedIcon("images/resources/" + res + ".png", iconColor);
painter.drawImage(QRect(rowRect.left() + 6, rowRect.top() + (rowRect.height() - 18) / 2, 18, 18), resIcon);
// Resource Name
QString resName = Localization::instance().get(QString("ui.resources.%1").arg(res), res);
painter.setFont(QFont("sans-serif", 8, isHovered || isActive ? QFont::Bold : QFont::Normal));
painter.setPen(isActive ? QColor(100, 255, 150) : (isHovered ? QColor(255, 255, 255) : QColor(200, 215, 230)));
painter.drawText(QRect(rowRect.left() + 28, rowRect.top(), 80, rowRect.height()), Qt::AlignLeft | Qt::AlignVCenter, resName);
// Best tag
if (isBest) {
QRect bestRect(rowRect.left() + 110, rowRect.top() + (rowRect.height() - 14) / 2, 34, 14);
painter.setBrush(QColor(80, 65, 20, 220));
painter.setPen(QPen(QColor(255, 205, 50), 1.0));
painter.drawRoundedRect(bestRect, 3, 3);
painter.setFont(QFont("sans-serif", 6, QFont::Bold));
painter.setPen(QColor(255, 220, 80));
painter.drawText(bestRect, Qt::AlignCenter, Localization::instance().get("ui.city.best_yield", "BEST"));
}
// Active indicator or yield amount
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(isActive ? QColor(80, 255, 120) : (yieldVal > 0 ? QColor(160, 230, 255) : QColor(130, 145, 160)));
QString yieldText = QString("+%L1").arg(yieldVal);
painter.drawText(QRect(rowRect.right() - 70, rowRect.top(), 64, rowRect.height()), Qt::AlignRight | Qt::AlignVCenter, yieldText);
currY += rowH;
}
}
void QSceneRenderer::mousePressEvent(QMouseEvent *event)
{
// 0. If in City View: handle close button, project tabs/hits, cancel, and slot clicks
if (GameManager::instance().isInCityView()) {
City *city = GameManager::instance().getActiveCityView();
// 0a. Handle Resource Selection Menu clicks (if open)
if (m_resourceMenuOpen) {
if (m_resourceMenuCloseBtnRect.contains(event->pos())) {
m_resourceMenuOpen = false;
update();
return;
}
for (const auto &choice : m_cityResourceChoiceHits) {
if (choice.rect.contains(event->pos())) {
if (city) {
int tx = m_resourceMenuTx;
int ty = m_resourceMenuTy;
city->setTilePreferredResource(tx, ty, choice.res);
QVector2D w = Pathfinder::hexToWorld(tx, ty);
float gz = Unit::getTerrainHeight(w.x(), w.y(), m_point, m_numPoints05);
QString resName = Localization::instance().get(QString("ui.resources.%1").arg(choice.res), choice.res);
if (city->isTileWorked(tx, ty)) {
// Already working: switch active producing resource
QString msg = Localization::instance().get("ui.city.resource_switched", "Producing %1 (+%2)").arg(resName).arg(choice.amount);
GameManager::instance().addFloatingText(msg, QVector3D(w.x(), w.y(), gz + 0.8f), QColor(80, 255, 120));
} else {
if (city->getAvailablePop() > 0) {
city->assignWorker(tx, ty, choice.res);
QString msg = Localization::instance().get("ui.city.worker_assigned_res", "+1 Pop: %1 (+%2)").arg(resName).arg(choice.amount);
GameManager::instance().addFloatingText(msg, QVector3D(w.x(), w.y(), gz + 0.8f), QColor(80, 255, 120));
} else {
QString msg = Localization::instance().get("ui.city.resource_preferred", "Slot set to %1 (+%2)").arg(resName).arg(choice.amount);
GameManager::instance().addFloatingText(msg, QVector3D(w.x(), w.y(), gz + 0.8f), QColor(255, 220, 80));
}
}
}
m_resourceMenuOpen = false;
update();
return;
}
}
// Clicked outside resource menu: dismiss popup
m_resourceMenuOpen = false;
update();
return;
}
if (m_closeCityViewBtnRect.contains(event->pos())) {
m_resourceMenuOpen = false;
m_slotPressPending = false;
cameraPosition = m_savedCamPos;
m_zoom = m_savedZoom;
zRot = m_savedZRot;
GameManager::instance().exitCityView();
update();
return;
}
// Tab switches
if (m_tabBuildingsRect.contains(event->pos())) {
m_cityProjectTab = 0;
update();
return;
}
if (m_tabUnitsRect.contains(event->pos())) {
m_cityProjectTab = 1;
update();
return;
}
// Cancel project
if (city && m_cancelProjectBtnRect.contains(event->pos())) {
GameManager::instance().cancelCityProject(city->id);
update();
return;
}
// Project selection clicks
if (city) {
for (const auto &pHit : m_cityProjectHits) {
if (pHit.rect.contains(event->pos())) {
GameManager::instance().setCityProject(city->id, pHit.type, pHit.id);
update();
return;
}
}
}
// Slot pressed: initiate long press timer for hold-to-change resource
for (const auto &slot : m_citySlotHits) {
if (slot.rect.contains(event->pos())) {
if (city) {
m_slotPressPending = true;
m_slotLongPressTriggered = false;
m_pressedSlotTx = slot.tx;
m_pressedSlotTy = slot.ty;
m_slotPressPos = event->pos();
m_slotPressElapsed.start();
int dur = TerrainYieldManager::instance().getCityConfig().slotLongPressDurationMs;
m_slotLongPressTimer->start(std::max(100, dur));
update();
return;
}
}
}
// 2. Buy tile clicks
for (const auto &buyHit : m_cityBuyTileHits) {
if (buyHit.rect.contains(event->pos())) {
if (city) {
GameManager::instance().buyTileForCity(city->id, buyHit.tx, buyHit.ty);
update();
return;
}
}
}
return; // In City View, ignore all other clicks
}
// Check if clicked on a City Banner to open City View
for (const auto &cb : m_cityBannerHits) {
if (cb.rect.contains(event->pos())) {
m_savedCamPos = cameraPosition;
m_savedZoom = m_zoom;
m_savedZRot = zRot;
GameManager::instance().enterCityView(cb.cityId);
update();
return;
}
}
// 1. Zoom Out / Reset / In buttons
if (getZoomOutButtonRect().contains(event->pos())) {
m_minimapZoom = std::max(1.0f, m_minimapZoom / 1.5f);
update();
return;
}
if (getZoomResetButtonRect().contains(event->pos())) {
m_minimapZoom = 1.0f;
update();
return;
}
if (getZoomInButtonRect().contains(event->pos())) {
m_minimapZoom = std::min(8.0f, m_minimapZoom * 1.5f);
update();
return;
}
// 2. Minimap click & drag
QRect mapRect = getMinimapRect();
if (mapRect.contains(event->pos())) {
m_draggingMinimap = true;
QVector2D wPos = minimapToWorld(event->pos(), mapRect);
cameraPosition.setX(wPos.x());
cameraPosition.setY(wPos.y());
update();
return;
}
// 3. Sim Speed Buttons [1x] [2x] [5x]
if (m_speed1xRect.contains(event->pos())) {
GameManager::instance().setSimSpeed(1.0f);
update();
return;
}
if (m_speed2xRect.contains(event->pos())) {
GameManager::instance().setSimSpeed(2.0f);
update();
return;
}
if (m_speed5xRect.contains(event->pos())) {
GameManager::instance().setSimSpeed(5.0f);
update();
return;
}
// 4. Action Bubbles clicked
for (const auto &ab : m_activeActionBubbles) {
if (ab.rect.contains(event->pos())) {
if (ab.actionId == "verplaatsen") {
if (GameManager::instance().currentActionMode() == ActionMode::Move) {
GameManager::instance().setActionMode(ActionMode::None);
} else {
GameManager::instance().setActionMode(ActionMode::Move);
}
} else if (ab.actionId == "aanvallen") {
if (GameManager::instance().currentActionMode() == ActionMode::Attack) {
GameManager::instance().setActionMode(ActionMode::None);
} else {
GameManager::instance().setActionMode(ActionMode::Attack);
}
} else if (ab.actionId == "steden_bouwen" || ab.actionId == "build_city") {
GameManager::instance().commandBuildCitySelected(m_point, m_numPoints05);
} else if (ab.actionId == "boerderij_bouwen" || ab.actionId == "build_farm") {
GameManager::instance().commandBuildFarmSelected(m_point, m_numPoints05);
} else if (ab.actionId == "build_road" || ab.actionId == "weg_bouwen") {
GameManager::instance().commandBuildRoadSelected(m_point, m_numPoints05);
}
update();
return;
}
}
// Compute view matrix for raycasting and 3D screen tests
QMatrix4x4 vMatrix;
cameraUpDirection = cameraTransformation * QVector3D(0, 0, 1);
QVector3D lookat = getCameraLookAt();
vMatrix.lookAt(cameraPosition, lookat, cameraUpDirection);
// 5. Left Click: Check if directly clicked on any unit's Druppel pin
if (event->button() == Qt::LeftButton) {
for (const auto &u : GameManager::instance().getUnits()) {
if (u->isDead()) continue;
bool inFront = false;
QVector3D topPoint = u->worldPos + QVector3D(0, 0, 0.38f);
QPointF tipPos = UnitRenderer::projectToScreen(topPoint, Projection, vMatrix, width(), height(), inFront);
if (!inFront) continue;
QPointF headCenter(tipPos.x(), tipPos.y() - 25.0f);
float distSq = (event->x() - headCenter.x()) * (event->x() - headCenter.x()) +
(event->y() - headCenter.y()) * (event->y() - headCenter.y());
if (distSq <= 18.0f * 18.0f) {
// If in attack mode and clicked enemy: attack!
Unit *sel = GameManager::instance().getSelectedUnit();
if (sel && GameManager::instance().currentActionMode() == ActionMode::Attack && u->playerId != sel->playerId) {
GameManager::instance().commandAttack(u.get());
} else {
GameManager::instance().selectUnit(u->id);
}
update();
return;
}
}
}
// 6. Terrain hex picking via Raycast
int hitX = -1, hitY = -1;
bool hitTerrain = screenToHex(event->pos(), vMatrix, hitX, hitY);
if (hitTerrain) {
Unit *selected = GameManager::instance().getSelectedUnit();
if (event->button() == Qt::LeftButton) {
if (selected) {
if (GameManager::instance().currentActionMode() == ActionMode::Move) {
GameManager::instance().commandMoveSelected(hitX, hitY, m_point, m_numPoints05);
GameManager::instance().setActionMode(ActionMode::None);
update();
return;
} else if (GameManager::instance().currentActionMode() == ActionMode::Attack) {
Unit *enemy = GameManager::instance().getUnitAt(hitX, hitY);
if (enemy && enemy->playerId != selected->playerId) {
GameManager::instance().commandAttack(enemy);
}
update();
return;
}
}
// Check if there is a unit on hit tile to select
Unit *unitOnTile = GameManager::instance().getUnitAt(hitX, hitY);
if (unitOnTile) {
GameManager::instance().selectUnit(unitOnTile->id);
} else {
City *cityOnTile = GameManager::instance().getCityAt(hitX, hitY);
if (cityOnTile) {
m_savedCamPos = cameraPosition;
m_savedZoom = m_zoom;
m_savedZRot = zRot;
GameManager::instance().enterCityView(cityOnTile->id);
} else if (!m_unitCardRect.contains(event->pos())) {
GameManager::instance().deselectUnit();
}
}
update();
return;
} else if (event->button() == Qt::RightButton) {
// Right click: Attack enemy unit or move selected unit directly to hex!
if (selected) {
Unit *enemy = GameManager::instance().getUnitAt(hitX, hitY);
if (enemy && enemy->playerId != selected->playerId) {
GameManager::instance().commandAttack(enemy);
} else {
GameManager::instance().commandMoveSelected(hitX, hitY, m_point, m_numPoints05);
}
update();
return;
}
}
}
lastPos = event->pos();
}
void QSceneRenderer::mouseMoveEvent(QMouseEvent *event)
{
m_mouseCurrentPos = event->pos();
// Disable panning, rotation and minimap drag in City View
if (GameManager::instance().isInCityView()) {
if (m_slotPressPending) {
if ((event->pos() - m_slotPressPos).manhattanLength() > 8) {
if (m_slotLongPressTimer && m_slotLongPressTimer->isActive()) {
m_slotLongPressTimer->stop();
}
m_slotPressPending = false;
}
}
update();
return;
}
QRect mapRect = getMinimapRect();
if (m_draggingMinimap && (event->buttons() & Qt::LeftButton)) {
QVector2D wPos = minimapToWorld(event->pos(), mapRect);
cameraPosition.setX(wPos.x());
cameraPosition.setY(wPos.y());
update();
return;
}
if (event->buttons() & Qt::RightButton) {
int dx = event->x() - lastPos.x();
zRot -= dx * 0.5f;
if (zRot < 0.0f) zRot += 360.0f;
if (zRot >= 360.0f) zRot -= 360.0f;
update();
}
lastPos = event->pos();
}
void QSceneRenderer::mouseReleaseEvent(QMouseEvent *event)
{
Q_UNUSED(event);
m_draggingMinimap = false;
if (m_slotLongPressTimer && m_slotLongPressTimer->isActive()) {
m_slotLongPressTimer->stop();
}
if (m_slotPressPending && !m_slotLongPressTriggered) {
// Short click on slot! Toggle worker assignment.
if (GameManager::instance().isInCityView()) {
City *city = GameManager::instance().getActiveCityView();
if (city) {
int tx = m_pressedSlotTx;
int ty = m_pressedSlotTy;
QVector2D w = Pathfinder::hexToWorld(tx, ty);
float gz = Unit::getTerrainHeight(w.x(), w.y(), m_point, m_numPoints05);
if (city->isTileWorked(tx, ty)) {
city->unassignWorker(tx, ty);
QString msg = Localization::instance().get("ui.city.worker_freed", "Pop worker freed");
GameManager::instance().addFloatingText(msg, QVector3D(w.x(), w.y(), gz + 0.8f), QColor(255, 220, 80));
} else {
if (city->getAvailablePop() > 0) {
QString bestRes = GameManager::instance().getBestTileResource(tx, ty, m_point, m_numPoints05);
QString res = city->getEffectiveResource(tx, ty, bestRes);
city->assignWorker(tx, ty, res);
QString msg = Localization::instance().get("ui.city.worker_assigned", "+1 Pop working");
GameManager::instance().addFloatingText(msg, QVector3D(w.x(), w.y(), gz + 0.8f), QColor(80, 255, 120));
} else {
QString msg = Localization::instance().get("ui.city.no_free_pop", "No free Pop!");
GameManager::instance().addFloatingText(msg, QVector3D(w.x(), w.y(), gz + 0.8f), QColor(255, 80, 80));
}
}
update();
}
}
}
m_slotPressPending = false;
m_slotLongPressTriggered = false;
}
void QSceneRenderer::keyPressEvent(QKeyEvent *e)
{
// In City View: disable WASD panning and Q/E rotation; only allow Escape to exit
if (GameManager::instance().isInCityView()) {
if (e->key() == Qt::Key_Escape) {
if (m_resourceMenuOpen) {
m_resourceMenuOpen = false;
update();
return;
}
m_slotPressPending = false;
cameraPosition = m_savedCamPos;
m_zoom = m_savedZoom;
zRot = m_savedZRot;
GameManager::instance().exitCityView();
update();
}
return;
}
float speed = (e->modifiers() & Qt::ShiftModifier) ? 1.2f : 0.4f;
// Movement & camera controls
if (e->key() == Qt::Key_A && !(e->modifiers() & Qt::ControlModifier)) {
cameraPosition.setX(cameraPosition.x() + speed * cos(PI * (zRot + 90) / 180.0f));
cameraPosition.setY(cameraPosition.y() + speed * sin(PI * (zRot + 90) / 180.0f));
}
else if (e->key() == Qt::Key_D) {
cameraPosition.setX(cameraPosition.x() + speed * cos(PI * (zRot - 90) / 180.0f));
cameraPosition.setY(cameraPosition.y() + speed * sin(PI * (zRot - 90) / 180.0f));
}
else if (e->key() == Qt::Key_W) {
cameraPosition.setX(cameraPosition.x() + speed * cos(PI * (zRot + 0) / 180.0f));
cameraPosition.setY(cameraPosition.y() + speed * sin(PI * (zRot + 0) / 180.0f));
}
else if (e->key() == Qt::Key_S) {
cameraPosition.setX(cameraPosition.x() + speed * cos(PI * (zRot + 180) / 180.0f));
cameraPosition.setY(cameraPosition.y() + speed * sin(PI * (zRot + 180) / 180.0f));
}
else if (e->key() == Qt::Key_E) {
zRot -= 2.0f;
if (zRot < 0.0f) zRot += 360.0f;
}
else if (e->key() == Qt::Key_Q) {
zRot += 2.0f;
if (zRot >= 360.0f) zRot -= 360.0f;
}
// Unit action hotkeys
Unit *sel = GameManager::instance().getSelectedUnit();
if (e->key() == Qt::Key_M && sel) {
GameManager::instance().setActionMode(
GameManager::instance().currentActionMode() == ActionMode::Move ? ActionMode::None : ActionMode::Move
);
}
else if (e->key() == Qt::Key_K && sel && sel->config.canAttack()) {
GameManager::instance().setActionMode(
GameManager::instance().currentActionMode() == ActionMode::Attack ? ActionMode::None : ActionMode::Attack
);
}
else if (e->key() == Qt::Key_B && sel && sel->config.canBuildCity()) {
GameManager::instance().commandBuildCitySelected(m_point, m_numPoints05);
}
else if (e->key() == Qt::Key_F && sel && sel->config.canBuildFarm()) {
GameManager::instance().commandBuildFarmSelected(m_point, m_numPoints05);
}
else if (e->key() == Qt::Key_R && sel && sel->config.canBuildRoad()) {
GameManager::instance().commandBuildRoadSelected(m_point, m_numPoints05);
}
else if (e->key() == Qt::Key_Escape) {
if (GameManager::instance().currentActionMode() != ActionMode::None) {
GameManager::instance().setActionMode(ActionMode::None);
} else if (sel && sel->hasQueuedAction()) {
sel->clearActionQueue();
GameManager::instance().addFloatingText(
Localization::instance().get("ui.unit.queue_cleared", "Queued actions cancelled"),
sel->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 180, 80));
} else {
GameManager::instance().deselectUnit();
}
}
// Speed hotkeys: 1, 2, 5
else if (e->key() == Qt::Key_1) {
GameManager::instance().setSimSpeed(1.0f);
}
else if (e->key() == Qt::Key_2) {
GameManager::instance().setSimSpeed(2.0f);
}
else if (e->key() == Qt::Key_3 || e->key() == Qt::Key_5) {
GameManager::instance().setSimSpeed(5.0f);
}
update();
}
void QSceneRenderer::wheelEvent(QWheelEvent *e)
{
// Disable zooming in City View
if (GameManager::instance().isInCityView()) {
return;
}
QRect card = getMinimapCardRect();
if (card.contains(e->pos()) || getZoomOutButtonRect().contains(e->pos()) || getZoomInButtonRect().contains(e->pos())) {
if (e->delta() > 0) {
m_minimapZoom = std::min(8.0f, m_minimapZoom * 1.4f);
} else {
m_minimapZoom = std::max(1.0f, m_minimapZoom / 1.4f);
}
update();
return;
}
m_zoom -= e->delta() / 100.0f;
if (m_zoom < m_cameraMinZoom) m_zoom = m_cameraMinZoom;
if (m_zoom > m_cameraMaxZoom) m_zoom = m_cameraMaxZoom;
update();
}
void QSceneRenderer::initializeGL()
{
glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_LESS);
m_unitRenderer.initialize();
}
void QSceneRenderer::resizeGL(int width, int height)
{
Projection = QMatrix4x4();
Projection.perspective(45.0f, (float) width / (float) height, 0.1f, 100.0f);
}
void QSceneRenderer::paintGL()
{
glEnable(GL_DEPTH_TEST);
QMatrix4x4 cameraTrans;
QMatrix4x4 vMatrix;
City *cityView = GameManager::instance().getActiveCityView();
int cityViewRadius = cityView ? TerrainYieldManager::instance().getCityConfig().maxWorkableRadius : -1;
cameraTrans.rotate(zRot, 0, 0, 1);
cameraUpDirection = cameraTrans * QVector3D(0, 0, 1);
QVector3D eyePos;
QVector3D lookat;
if (cityView) {
float effectiveZoom = TerrainYieldManager::instance().getCityConfig().cityViewZoom;
float pitchDist = effectiveZoom * effectiveZoom / 5.0f;
float rad = zRot * PI / 180.0f;
lookat = cityView->worldPos;
eyePos = QVector3D(
lookat.x() - pitchDist * cos(rad),
lookat.y() - pitchDist * sin(rad),
effectiveZoom
);
cameraPosition = eyePos;
} else {
cameraPosition.setZ(m_zoom);
eyePos = cameraPosition;
lookat = getCameraLookAt();
}
vMatrix.lookAt(eyePos, lookat, cameraUpDirection);
if (cityView) {
glClearColor(0.06f, 0.08f, 0.12f, 1.0f);
} else {
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
}
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// Update simulation
float currentTime = m_elapsedTimer.elapsed() / 1000.0f;
float deltaTime = m_lastFrameTime > 0.0f ? std::min(0.1f, currentTime - m_lastFrameTime) : 0.016f;
m_lastFrameTime = currentTime;
GameManager::instance().update(deltaTime, m_point, m_numPoints05);
// Visible terrain tiles bounding box
float minWorldX = std::min(eyePos.x(), lookat.x());
float maxWorldX = std::max(eyePos.x(), lookat.x());
float minWorldY = std::min(eyePos.y(), lookat.y());
float maxWorldY = std::max(eyePos.y(), lookat.y());
// Margin around visible focal area (provides ample buffer for mountains and rotating view)
float margin = cityView ? 20.0f : std::max(10.0f, m_zoom * 1.4f + 4.0f);
minWorldX -= margin;
maxWorldX += margin;
minWorldY -= margin;
maxWorldY += margin;
float tileStepX = LOD_0_SIZE * 0.75f;
float tileStepY = LOD_0_SIZE_Y * 2.0f;
int minTileX = std::max(0, (int)floor(minWorldX / tileStepX));
int maxTileX = std::min(MAP_WIDTH - 1, (int)ceil(maxWorldX / tileStepX));
int minTileY = std::max(0, (int)floor(minWorldY / tileStepY));
int maxTileY = std::min(MAP_HEIGHT - 1, (int)ceil(maxWorldY / tileStepY));
int initMinX = std::max(0, minTileX - 1);
int initMaxX = std::min(MAP_WIDTH - 1, maxTileX + 1);
int initMinY = std::max(0, minTileY - 1);
int initMaxY = std::min(MAP_HEIGHT - 1, maxTileY + 1);
for (int y = initMinY; y <= initMaxY; ++y) {
for (int x = initMinX; x <= initMaxX; ++x) {
tiles->operator()(x, y).ensureInitialized(this, x, y);
}
}
for (int y = minTileY; y <= maxTileY; ++y) {
for (int x = minTileX; x <= maxTileX; ++x) {
tiles->operator()(x, y).ensureNormalsBuilt();
}
}
// 1. Render terrain tiles (in city view: ONLY render tiles within cityViewRadius!)
for (int y = minTileY; y <= maxTileY; ++y) {
for (int x = minTileX; x <= maxTileX; ++x) {
if (cityView && Pathfinder::hexDistance(cityView->tileX, cityView->tileY, x, y) > cityViewRadius) {
continue;
}
tiles->operator()(x, y).paint(Projection, eyePos, cameraUpDirection, vMatrix, currentTime);
}
}
// 2. Render 3D Units (Cylinders)
float fSunAngle = 45.0f;
QVector3D vSunPos(-sin(fSunAngle * PI / 180.0f), -cos(fSunAngle * PI / 180.0f), 0.6f);
vSunPos.normalize();
Unit *selected = GameManager::instance().getSelectedUnit();
m_unitRenderer.renderUnits(
GameManager::instance().getUnits(),
GameManager::instance().getCities(),
GameManager::instance().players(),
Projection,
vMatrix,
vSunPos,
selected ? selected->id : -1,
cityViewRadius,
cityView ? cityView->tileX : -1,
cityView ? cityView->tileY : -1
);
// 3. Render 2D UI & HUD Overlays via QPainter
QPainter painter(this);
drawCityBorders(painter, vMatrix);
drawRoadsOverlay(painter, vMatrix);
drawImprovementsOverlay(painter, vMatrix);
if (!cityView) {
drawPathAndRange(painter, selected, vMatrix);
drawMinimap(painter);
drawTickBar(painter);
drawUnitsOverlay(painter, vMatrix);
drawFloatingTexts(painter, vMatrix);
if (selected) {
drawUnitCard(painter, selected);
}
} else {
drawUnitsOverlay(painter, vMatrix);
drawFloatingTexts(painter, vMatrix);
drawCityViewOverlay(painter, vMatrix);
}
painter.end();
}