Files
Ascent-of-Man/qscenerenderer.cpp
T

2437 lines
109 KiB
C++

#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);
}
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 res = isWorked ? city->getWorkedResource(hx, hy) : yCfg->primaryResource;
int amount = yCfg->getYield(res);
const TileImprovement *imp = GameManager::instance().getImprovementAt(hx, hy);
if (imp) {
const ImprovementConfig *iCfg = TerrainYieldManager::instance().getImprovementConfig(imp->improvementId);
if (iCfg && iCfg->yieldBonus.contains(res)) {
amount += iCfg->yieldBonus.value(res, 0);
}
}
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);
// 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++;
}
}
}
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();
if (m_closeCityViewBtnRect.contains(event->pos())) {
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;
}
}
}
for (const auto &slot : m_citySlotHits) {
if (slot.rect.contains(event->pos())) {
if (city) {
QVector2D w = Pathfinder::hexToWorld(slot.tx, slot.ty);
float gz = Unit::getTerrainHeight(w.x(), w.y(), m_point, m_numPoints05);
if (city->isTileWorked(slot.tx, slot.ty)) {
city->unassignWorker(slot.tx, slot.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) {
city->assignWorker(slot.tx, slot.ty, slot.defaultRes);
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();
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()) {
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;
}
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) {
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();
}