Files
Ascent-of-Man/qscenerenderer.cpp
T

3956 lines
185 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 <QGuiApplication>
#include <QToolTip>
#include <QDebug>
#include "pathfinder.h"
#include "yamlparser.h"
#include "localization.h"
GLuint vertexbuffer;
GLuint VertexArrayID;
QSceneRenderer::QSceneRenderer(QWidget* parent): QOpenGLWidget(parent)
{
setFocusPolicy(Qt::FocusPolicy::StrongFocus);
setMouseTracking(true);
installEventFilter(this);
QImage img = QImage("/home/bart/git/Ascent-of-Man/images/heightmap.jpg").convertToFormat(QImage::Format_RGB32);
QImage typeImg = QImage("/home/bart/git/Ascent-of-Man/images/typemap.png").convertToFormat(QImage::Format_RGB32);
QImage riverImg = QImage("/home/bart/git/Ascent-of-Man/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/git/Ascent-of-Man/images/minimap.png");
if (m_image_minimap.isNull()) {
m_image_minimap = QImage("/home/bart/git/Ascent-of-Man/images/rivermap_preview.png");
}
// Initialize GameManager and spawn demo units
GameManager::instance().init("/home/bart/git/Ascent-of-Man", 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/git/Ascent-of-Man/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/git/Ascent-of-Man/" + 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 = 840, 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; Science shows per-tick rate, not saved stockpile)
const Player *p1 = GameManager::instance().players().getPlayer(1);
m_scienceBarHitRect = QRect();
m_techTreeBtnRect = QRect();
if (p1) {
int sciRate = GameManager::instance().calculatePlayerSciencePerTick(1, m_point, m_numPoints05);
struct ResItem { QString icon; QString text; QColor col; bool isScience; };
std::vector<ResItem> natRes = {
{"images/resources/cultuur.png", QString("%L1").arg(p1->culture), QColor(215, 110, 255), false},
{"images/resources/kennis.png", Localization::instance().get("ui.tech_tree.science_rate", "+%L1/tick").arg(sciRate), QColor(60, 190, 255), true},
{"images/resources/goud.png", QString("%L1").arg(p1->gold), QColor(255, 210, 50), false},
{"images/resources/geloof.png", QString("%L1").arg(p1->faith), QColor(255, 230, 80), false}
};
int startX = barRect.left() + 150;
int itemW = 92;
for (size_t i = 0; i < natRes.size(); ++i) {
int ix = startX + (int)i * itemW;
QRect slotRect(ix - 2, barRect.top() + 4, itemW - 2, 26);
if (natRes[i].isScience) {
m_scienceBarHitRect = slotRect;
}
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, natRes[i].text);
}
// 2b. Active Research / Tech Tree Button
m_techTreeBtnRect = QRect(barRect.left() + 520, barRect.top() + 5, 158, 24);
bool treeOpen = GameManager::instance().isTechTreeOpen();
bool hasRes = p1->hasActiveResearch();
painter.setBrush(treeOpen ? QColor(35, 80, 130, 245) : (hasRes ? QColor(22, 42, 68, 235) : QColor(45, 35, 25, 235)));
painter.setPen(QPen(treeOpen ? QColor(100, 220, 255) : (hasRes ? QColor(70, 175, 240) : QColor(255, 190, 60)), 1.3));
painter.drawRoundedRect(m_techTreeBtnRect, 4, 4);
if (hasRes) {
QString activeTechId = p1->getActiveResearchTech();
const TechConfig *tCfg = GameManager::instance().techTree().getTech(activeTechId);
QString tName = tCfg ? tCfg->name : activeTechId;
int prog = p1->getTechProgress(activeTechId);
int cost = tCfg ? std::max(1, tCfg->scienceCost) : 1;
float frac = std::max(0.0f, std::min(1.0f, (float)prog / (float)cost));
int pct = (int)(frac * 100.0f);
// Mini progress bar at bottom of button
QRect miniBg(m_techTreeBtnRect.left() + 4, m_techTreeBtnRect.bottom() - 4, m_techTreeBtnRect.width() - 8, 3);
painter.setBrush(QColor(15, 22, 32));
painter.setPen(Qt::NoPen);
painter.drawRoundedRect(miniBg, 1, 1);
QRect miniFg(m_techTreeBtnRect.left() + 4, m_techTreeBtnRect.bottom() - 4, (int)((m_techTreeBtnRect.width() - 8) * frac), 3);
painter.setBrush(QColor(60, 210, 255));
painter.drawRoundedRect(miniFg, 1, 1);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(220, 245, 255));
QString btnText = QString("%1 (%2%) [T]").arg(tName).arg(pct);
painter.drawText(m_techTreeBtnRect.adjusted(4, 0, -4, -3), Qt::AlignCenter, btnText);
} else {
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(255, 215, 90));
painter.drawText(m_techTreeBtnRect, Qt::AlignCenter,
Localization::instance().get("ui.tech_tree.choose_research", "Choose Tech [T]"));
}
}
// 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;
if (c.hasActiveProject() && !c.projectQueue.empty()) {
bWidth += 35;
}
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);
if (!c.projectQueue.empty()) {
bannerText += QString(" (+%1)").arg(c.projectQueue.size());
}
}
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 isMode = false;
bool isActive = false;
QString iconFile = "images/actions/move.png";
QString tip = Localization::instance().get("ui.unit.tip_move", "Move (M) - Click target tile (Shift+Click to queue)");
if (act == "verplaatsen") {
isMode = (GameManager::instance().currentActionMode() == ActionMode::Move);
isActive = isMode;
iconFile = "images/actions/move.png";
tip = Localization::instance().get("ui.unit.tip_move", "Move (M) - Click target tile (Shift+Click to queue)");
} else if (act == "aanvallen") {
isMode = (GameManager::instance().currentActionMode() == ActionMode::Attack);
bool isQueued = (unit->currentAction.type == UnitActionType::Attack) || unit->hasQueuedActionType(UnitActionType::Attack);
isActive = isMode || isQueued;
iconFile = "images/actions/attack.png";
tip = Localization::instance().get("ui.unit.tip_attack", "Attack (A) [Range: %1] - Click enemy (Shift+Click to queue)").arg(unit->config.attackRange);
} else if (act == "steden_bouwen" || act == "build_city") {
isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildCity);
bool isQueued = (unit->currentAction.type == UnitActionType::BuildCity) || unit->hasQueuedActionType(UnitActionType::BuildCity);
isActive = isMode || isQueued;
iconFile = "images/actions/build_city.png";
tip = isQueued
? Localization::instance().get("ui.unit.city_queued", "Found City queued")
: Localization::instance().get("ui.unit.tip_build_city", "Found City (B) - Click target tile (Shift+Click to queue)");
} else if (act == "boerderij_bouwen" || act == "build_farm") {
isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildFarm);
bool isBuildingFarm = unit->isBuilding() && !unit->buildingImprovementId.startsWith("road");
bool isQueued = (unit->currentAction.type == UnitActionType::BuildImprovement) || unit->hasQueuedActionType(UnitActionType::BuildImprovement);
isActive = isMode || isBuildingFarm || isQueued;
iconFile = "images/actions/farm.png";
if (isBuildingFarm) {
int pct = (int)(std::min(1.0f, unit->buildProgress) * 100.0f);
tip = Localization::instance().get("ui.unit.tip_farm_building", "Busy building... %1% (Click to plan next target)").arg(pct);
} else if (isQueued) {
tip = Localization::instance().get("ui.unit.tip_farm_queued", "Build Farm (Queued - Click to plan target)");
} else {
tip = Localization::instance().get("ui.unit.tip_build_farm", "Build Farm (F) - Click target tile (Shift+Click to queue)");
}
} else if (act == "build_road" || act == "weg_bouwen") {
isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildRoad);
bool isBuildingRoad = unit->isBuilding() && unit->buildingImprovementId.startsWith("road");
bool hasRoute = unit->hasRoadRoute();
bool isQueued = (unit->currentAction.type == UnitActionType::BuildRoad) || unit->hasQueuedActionType(UnitActionType::BuildRoad);
isActive = isMode || isBuildingRoad || hasRoute || isQueued;
iconFile = "images/actions/road.png";
if (isBuildingRoad) {
int pct = (int)(std::min(1.0f, unit->buildProgress) * 100.0f);
tip = Localization::instance().get("ui.unit.tip_road_building", "Busy building road... %1% (Click to plan next target)").arg(pct);
} else if (hasRoute) {
int cur = (int)unit->roadRouteIndex + 1;
int total = (int)unit->roadRoute.size();
tip = Localization::instance().get("ui.unit.status_road_route", "Road Route: %1/%2").arg(cur).arg(total);
} else if (isQueued) {
tip = Localization::instance().get("ui.unit.tip_road_queued", "Build Road (Queued - Click to plan target)");
} else {
tip = Localization::instance().get("ui.unit.tip_build_road", "Build Road (R) - Click target tile (Shift+Click to queue)");
}
}
// Draw action bubble
painter.setBrush(isMode ? QColor(55, 105, 175, 250) : (isActive ? QColor(40, 80, 140, 240) : QColor(20, 26, 38, 230)));
painter.setPen(QPen(isMode ? QColor(100, 235, 255) : (isActive ? QColor(255, 220, 50) : secondaryCol), (isMode || isActive) ? 2.0 : 1.2));
painter.drawEllipse(bCenter, bubbleR, bubbleR);
// Action icon inside bubble
QImage aIcon = getTintedIcon(iconFile, (isMode || 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)) {
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
int tw = std::max(110, painter.fontMetrics().horizontalAdvance(tip) + 14);
QRect tipRect((int)bCenter.x() - tw / 2, (int)bCenter.y() - 28, tw, 18);
painter.setBrush(QColor(15, 20, 30, 240));
painter.setPen(QPen(QColor(120, 160, 210), 1.0));
painter.drawRoundedRect(tipRect, 3, 3);
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);
auto drawFloatingBadge = [&](const QPointF &sPos, const QString &text, const QColor &borderCol, const QColor &textCol, int yOffset = -28) {
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
int tw = painter.fontMetrics().horizontalAdvance(text) + 14;
QRect badgeRect((int)sPos.x() - tw / 2, (int)sPos.y() + yOffset, tw, 18);
painter.setBrush(QColor(16, 22, 32, 235));
painter.setPen(QPen(borderCol, 1.2));
painter.drawRoundedRect(badgeRect, 4, 4);
painter.setPen(textCol);
painter.drawText(badgeRect, Qt::AlignCenter, text);
};
// 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. Road Route visualization if unit is constructing a multi-tile road route
if (unit->hasRoadRoute()) {
QPainterPath routeLine;
bool first = true;
for (size_t i = unit->roadRouteIndex; i < unit->roadRoute.size(); ++i) {
auto hex = unit->roadRoute[i];
QVector2D w = Pathfinder::hexToWorld(hex.first, hex.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.06f), Projection, vMatrix, width(), height(), inFront);
if (inFront) {
if (first) {
routeLine.moveTo(sPos);
first = false;
} else {
routeLine.lineTo(sPos);
}
painter.setPen(QPen(QColor(140, 95, 30, 200), 1.2));
painter.setBrush(QColor(255, 215, 60, 220));
painter.drawEllipse(sPos, 3.5, 3.5);
}
}
painter.setPen(QPen(QColor(255, 195, 45, 230), 2.8, Qt::DashLine));
painter.setBrush(Qt::NoBrush);
painter.drawPath(routeLine);
auto destHex = unit->roadRoute.back();
QVector2D destW = Pathfinder::hexToWorld(destHex.first, destHex.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.06f), Projection, vMatrix, width(), height(), inFront);
if (inFront) {
float pulse = (std::sin(m_elapsedTimer.elapsed() * 0.008f) + 1.0f) * 0.5f;
painter.setPen(QPen(QColor(255, 215, 50, (int)(180 + pulse * 75)), 2.2));
painter.setBrush(QColor(255, 190, 40, 45));
painter.drawEllipse(sDest, 14.0f, 14.0f);
painter.drawLine(sDest.x() - 18, sDest.y(), sDest.x() + 18, sDest.y());
painter.drawLine(sDest.x(), sDest.y() - 18, sDest.x(), sDest.y() + 18);
}
}
// 2b. Queued Action Chain visualization (shows all actions in unit->actionQueue chained from previous target tile)
if (!unit->actionQueue.empty()) {
int prevX = unit->tileX;
int prevY = unit->tileY;
GameManager::instance().getPlannedOriginTile(*unit, false, prevX, prevY);
for (size_t qi = 0; qi < unit->actionQueue.size(); ++qi) {
const UnitAction &qAct = unit->actionQueue[qi];
if (qAct.targetX < 0 || qAct.targetY < 0) continue;
QColor qCol(100, 210, 255, 210);
QString qBadge;
if (qAct.type == UnitActionType::Move) {
qCol = QColor(80, 210, 255, 210);
qBadge = Localization::instance().get("ui.unit.queue_badge_move", "#%1 Move").arg(qi + 1);
} else if (qAct.type == UnitActionType::BuildImprovement) {
qCol = QColor(90, 235, 130, 220);
qBadge = Localization::instance().get("ui.unit.queue_badge_farm", "#%1 Farm").arg(qi + 1);
} else if (qAct.type == UnitActionType::BuildCity) {
qCol = QColor(120, 245, 160, 220);
qBadge = Localization::instance().get("ui.unit.queue_badge_city", "#%1 Settle").arg(qi + 1);
} else if (qAct.type == UnitActionType::BuildRoad) {
qCol = QColor(255, 200, 65, 220);
qBadge = Localization::instance().get("ui.unit.queue_badge_road", "#%1 Road").arg(qi + 1);
} else if (qAct.type == UnitActionType::Attack) {
qCol = QColor(255, 85, 85, 220);
qBadge = Localization::instance().get("ui.unit.queue_badge_attack", "#%1 Attack").arg(qi + 1);
}
if (qAct.targetX != prevX || qAct.targetY != prevY) {
MovementDomain dom = (qAct.type == UnitActionType::BuildRoad) ? MovementDomain::Land : unit->config.movementDomain;
auto qPath = Pathfinder::findPath(prevX, prevY, qAct.targetX, qAct.targetY, m_point, m_numPoints05, dom, &unit->config);
if (qPath.size() > 1) {
QPainterPath qLine;
bool first = true;
for (const auto &wp : qPath) {
QVector2D w = Pathfinder::hexToWorld(wp.first, wp.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.06f), Projection, vMatrix, width(), height(), inFront);
if (inFront) {
if (first) {
qLine.moveTo(sPos);
first = false;
} else {
qLine.lineTo(sPos);
}
if (qAct.type == UnitActionType::BuildRoad) {
painter.setPen(QPen(QColor(140, 95, 30, 180), 1.0));
painter.setBrush(qCol);
painter.drawEllipse(sPos, 3.0, 3.0);
}
}
}
painter.setPen(QPen(qCol, 2.2, Qt::DotLine));
painter.setBrush(Qt::NoBrush);
painter.drawPath(qLine);
}
}
QVector2D destW = Pathfinder::hexToWorld(qAct.targetX, qAct.targetY);
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.06f), Projection, vMatrix, width(), height(), inFront);
if (inFront) {
painter.setPen(QPen(qCol, 1.8));
painter.setBrush(QColor(qCol.red(), qCol.green(), qCol.blue(), 40));
painter.drawEllipse(sDest, 11.0, 11.0);
drawFloatingBadge(sDest, qBadge, qCol, qCol.lighter(130), -26);
}
if (qAct.type == UnitActionType::Attack && qAct.targetUnitId >= 0) {
Unit *enemy = GameManager::instance().getUnitById(qAct.targetUnitId);
if (enemy && !enemy->isDead() && inFront) {
bool eInFront = false;
QPointF sEnemy = UnitRenderer::projectToScreen(enemy->worldPos + QVector3D(0, 0, 0.35f), Projection, vMatrix, width(), height(), eInFront);
if (eInFront) {
painter.setPen(QPen(QColor(255, 70, 70, 210), 2.0, Qt::DashLine));
painter.drawLine(sDest, sEnemy);
}
}
}
prevX = qAct.targetX;
prevY = qAct.targetY;
}
}
// 3. Target Tile Hover Preview when any ActionMode is active
ActionMode mode = GameManager::instance().currentActionMode();
if (mode != ActionMode::None) {
bool shiftHeld = (QGuiApplication::keyboardModifiers() & Qt::ShiftModifier) != 0;
int originX = unit->tileX;
int originY = unit->tileY;
GameManager::instance().getPlannedOriginTile(*unit, shiftHeld, originX, originY);
int hx = -1, hy = -1;
bool hasHoverHex = screenToHex(m_mouseCurrentPos, vMatrix, hx, hy);
auto wrapQueueText = [&](const QString &baseText) -> QString {
if (shiftHeld && (unit->hasActiveAction() || unit->hasQueuedAction())) {
return Localization::instance().get("ui.unit.preview_queue_prefix", "+Queue: %1").arg(baseText);
}
return baseText;
};
auto drawPreviewPathAndReticle = [&](const std::vector<std::pair<int, int>> &previewPath,
int destX, int destY,
const QColor &col, const QString &label,
bool validTarget, bool drawWaypoints) {
if (previewPath.size() > 1) {
QPainterPath previewLine;
bool first = true;
for (const auto &p : previewPath) {
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.06f), Projection, vMatrix, width(), height(), inFront);
if (inFront) {
if (first) {
previewLine.moveTo(sPos);
first = false;
} else {
previewLine.lineTo(sPos);
}
if (drawWaypoints) {
painter.setPen(QPen(QColor(30, 30, 30, 200), 1.0));
painter.setBrush(col);
painter.drawEllipse(sPos, 3.5, 3.5);
}
}
}
painter.setPen(QPen(col, 2.8, Qt::DashLine));
painter.setBrush(Qt::NoBrush);
painter.drawPath(previewLine);
}
QVector2D destW = Pathfinder::hexToWorld(destX, destY);
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.06f), Projection, vMatrix, width(), height(), inFront);
if (inFront) {
float pulse = (std::sin(m_elapsedTimer.elapsed() * 0.01f) + 1.0f) * 0.5f;
QColor rCol = validTarget ? col : QColor(255, 75, 75);
painter.setPen(QPen(QColor(rCol.red(), rCol.green(), rCol.blue(), (int)(180 + pulse * 75)), 2.5));
painter.setBrush(QColor(rCol.red(), rCol.green(), rCol.blue(), 50));
painter.drawEllipse(sDest, 16.0f, 16.0f);
painter.drawLine(sDest.x() - 22, sDest.y(), sDest.x() + 22, sDest.y());
painter.drawLine(sDest.x(), sDest.y() - 22, sDest.x(), sDest.y() + 22);
if (!label.isEmpty()) {
drawFloatingBadge(sDest, label, rCol, rCol.lighter(135), -34);
}
}
};
if (mode == ActionMode::Move && hasHoverHex && (hx != originX || hy != originY)) {
bool passable = TerrainMovementManager::instance().isHexPassable(hx, hy, unit->config.movementDomain, m_point, m_numPoints05, &unit->config);
std::vector<std::pair<int, int>> preview;
if (passable) {
preview = Pathfinder::findPath(originX, originY, hx, hy, m_point, m_numPoints05, unit->config.movementDomain, &unit->config);
}
bool valid = preview.size() > 1;
QString label = valid
? wrapQueueText(Localization::instance().get("ui.unit.preview_move", "Move (%1 hexes)").arg(preview.size() - 1))
: Localization::instance().get("ui.unit.impassable_terrain", "Impassable terrain!");
drawPreviewPathAndReticle(preview, hx, hy, QColor(80, 220, 255, 240), label, valid, false);
}
else if (mode == ActionMode::BuildRoad && unit->config.canBuildRoad() && hasHoverHex) {
bool passable = TerrainMovementManager::instance().isHexPassable(hx, hy, MovementDomain::Land, m_point, m_numPoints05, &unit->config);
std::vector<std::pair<int, int>> preview;
bool valid = false;
QString label;
if (passable) {
if (hx == originX && hy == originY) {
valid = !GameManager::instance().hasRoadAt(hx, hy);
label = valid
? wrapQueueText(Localization::instance().get("ui.unit.preview_road_here", "Build Road"))
: Localization::instance().get("ui.unit.road_already_here", "Tile already has a road!");
} else {
preview = Pathfinder::findPath(originX, originY, hx, hy, m_point, m_numPoints05, MovementDomain::Land, &unit->config);
valid = preview.size() > 1;
label = valid
? wrapQueueText(Localization::instance().get("ui.unit.preview_road", "Road Route (%1 hexes)").arg(preview.size() - 1))
: Localization::instance().get("ui.unit.no_path", "No reachable path!");
}
} else {
label = Localization::instance().get("ui.unit.cannot_build_road_water", "Roads cannot be built on water!");
}
drawPreviewPathAndReticle(preview, hx, hy, QColor(255, 220, 60, 240), label, valid, true);
}
else if (mode == ActionMode::BuildFarm && unit->config.canBuildFarm() && hasHoverHex) {
QString tType = TerrainYieldManager::instance().getTerrainType(hx, hy, m_point, m_numPoints05);
const ImprovementConfig *farmCfg = TerrainYieldManager::instance().getImprovementConfig("farm");
QStringList validTerrains = farmCfg ? farmCfg->validTerrains : QStringList{"grasland", "tundra"};
bool alreadyOccupied = GameManager::instance().hasImprovementAt(hx, hy) ||
(shiftHeld && unit->hasQueuedImprovementAt(hx, hy));
bool terrainValid = validTerrains.contains(tType) && !alreadyOccupied;
std::vector<std::pair<int, int>> preview;
bool pathValid = true;
if (hx != originX || hy != originY) {
preview = Pathfinder::findPath(originX, originY, hx, hy, m_point, m_numPoints05, unit->config.movementDomain, &unit->config);
pathValid = preview.size() > 1;
}
bool valid = terrainValid && pathValid;
QString label;
if (!valid) {
if (alreadyOccupied) {
label = Localization::instance().get("ui.unit.tile_occupied", "Tile already has an improvement!");
} else if (!validTerrains.contains(tType)) {
label = Localization::instance().get("ui.unit.unsuitable_terrain", "Unsuitable terrain!");
} else {
label = Localization::instance().get("ui.unit.no_path", "No reachable path!");
}
} else if (preview.size() > 1) {
label = wrapQueueText(Localization::instance().get("ui.unit.preview_farm_move", "Build Farm (%1 hexes)").arg(preview.size() - 1));
} else {
label = wrapQueueText(Localization::instance().get("ui.unit.preview_farm", "Build Farm"));
}
drawPreviewPathAndReticle(preview, hx, hy, QColor(90, 235, 125, 240), label, valid, false);
}
else if (mode == ActionMode::BuildCity && unit->config.canBuildCity() && hasHoverHex) {
bool passable = TerrainMovementManager::instance().isHexPassable(hx, hy, unit->config.movementDomain, m_point, m_numPoints05, &unit->config);
bool noCity = (GameManager::instance().getCityAt(hx, hy) == nullptr);
std::vector<std::pair<int, int>> preview;
bool pathValid = true;
if (hx != originX || hy != originY) {
preview = Pathfinder::findPath(originX, originY, hx, hy, m_point, m_numPoints05, unit->config.movementDomain, &unit->config);
pathValid = preview.size() > 1;
}
bool valid = passable && noCity && pathValid;
QString label;
if (!valid) {
if (!noCity) {
label = Localization::instance().get("ui.unit.city_already_here", "A city already exists here!");
} else {
label = Localization::instance().get("ui.unit.impassable_terrain", "Impassable terrain!");
}
} else if (preview.size() > 1) {
label = wrapQueueText(Localization::instance().get("ui.unit.preview_city_move", "Found City (%1 hexes)").arg(preview.size() - 1));
} else {
label = wrapQueueText(Localization::instance().get("ui.unit.preview_city", "Found City"));
}
drawPreviewPathAndReticle(preview, hx, hy, QColor(110, 245, 160, 240), label, valid, false);
}
else if (mode == 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(originX, originY, u->tileX, u->tileY);
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;
QColor rCol = (d <= range) ? QColor(255, 50, 50, (int)(180 + pulse * 75)) : QColor(255, 140, 60, (int)(150 + pulse * 70));
painter.setPen(QPen(rCol, 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);
}
}
if (hasHoverHex) {
Unit *hoveredEnemy = GameManager::instance().getUnitAt(hx, hy);
if (hoveredEnemy && hoveredEnemy->playerId != unit->playerId && !hoveredEnemy->isDead()) {
int d = Pathfinder::hexDistance(originX, originY, hoveredEnemy->tileX, hoveredEnemy->tileY);
std::vector<std::pair<int, int>> approachPath;
if (d > range) {
auto fullPath = Pathfinder::findPath(originX, originY, hoveredEnemy->tileX, hoveredEnemy->tileY,
m_point, m_numPoints05, unit->config.movementDomain, &unit->config);
for (const auto &wp : fullPath) {
approachPath.push_back(wp);
if (Pathfinder::hexDistance(wp.first, wp.second, hoveredEnemy->tileX, hoveredEnemy->tileY) <= range) {
break;
}
}
}
QString label = (approachPath.size() > 1)
? wrapQueueText(Localization::instance().get("ui.unit.preview_attack_move", "Attack (%1 hexes)").arg(approachPath.size() - 1))
: wrapQueueText(Localization::instance().get("ui.unit.preview_attack", "Attack"));
drawPreviewPathAndReticle(approachPath, hoveredEnemy->tileX, hoveredEnemy->tileY, QColor(255, 65, 65, 240), label, true, false);
}
}
}
}
}
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 : "Unknown").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->hasQueuedAction()) {
QString qSummary = unit->getQueuedActionSummary();
QString queuedInfo = Localization::instance().get("ui.unit.status_queued", "Queue (%1): %2")
.arg(unit->actionQueue.size()).arg(qSummary);
painter.setPen(QColor(100, 220, 140));
painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 286, 14), Qt::AlignLeft | Qt::AlignVCenter, queuedInfo);
} 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->hasRoadRoute()) {
int cur = (int)unit->roadRouteIndex + 1;
int total = (int)unit->roadRoute.size();
buildInfo += QString(" | ") + Localization::instance().get("ui.unit.status_road_route", "Road Route: %1/%2").arg(cur).arg(total);
} else 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, 286, 14), Qt::AlignLeft | Qt::AlignVCenter, buildInfo);
} else if (unit->hasRoadRoute()) {
int cur = (int)unit->roadRouteIndex + 1;
int total = (int)unit->roadRoute.size();
QString routeInfo = Localization::instance().get("ui.unit.status_road_route", "Road Route: %1/%2").arg(cur).arg(total);
painter.setPen(QColor(255, 200, 70));
painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 286, 14), Qt::AlignLeft | Qt::AlignVCenter, routeInfo);
} else 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, 286, 14), Qt::AlignLeft | Qt::AlignVCenter, combatInfo);
} 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, 286, 14), Qt::AlignLeft | Qt::AlignVCenter, builderInfo);
} else {
painter.drawText(QRect(cardRect.left() + 12, cardRect.top() + 94, 286, 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 isMode = false;
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) - Click target tile (Shift+Click to queue)");
if (act == "verplaatsen") {
isMode = (GameManager::instance().currentActionMode() == ActionMode::Move);
isActive = isMode;
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) - Click target tile (Shift+Click to queue)");
} else if (act == "aanvallen") {
isMode = (GameManager::instance().currentActionMode() == ActionMode::Attack);
bool isQueued = (unit->currentAction.type == UnitActionType::Attack) || unit->hasQueuedActionType(UnitActionType::Attack);
isActive = isMode || isQueued;
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] - Click enemy (Shift+Click to queue)").arg(unit->config.attackRange);
} else if (act == "steden_bouwen" || act == "build_city") {
isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildCity);
bool isQueued = (unit->currentAction.type == UnitActionType::BuildCity) || unit->hasQueuedActionType(UnitActionType::BuildCity);
isActive = isMode || isQueued;
iconFile = "images/actions/build_city.png";
label = Localization::instance().get("ui.unit.btn_build_city", "Settle [B]");
tip = isQueued
? Localization::instance().get("ui.unit.city_queued", "Found City queued")
: Localization::instance().get("ui.unit.tip_build_city", "Found City (B) - Click target tile (Shift+Click to queue)");
} else if (act == "boerderij_bouwen" || act == "build_farm") {
isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildFarm);
bool isBuildingFarm = unit->isBuilding() && !unit->buildingImprovementId.startsWith("road");
bool isQueued = (unit->currentAction.type == UnitActionType::BuildImprovement) || unit->hasQueuedActionType(UnitActionType::BuildImprovement);
isActive = isMode || isBuildingFarm || isQueued;
iconFile = "images/actions/farm.png";
if (isBuildingFarm) {
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% (Click to plan next target)").arg(pct);
} else if (isQueued) {
label = Localization::instance().get("ui.unit.btn_farm_queued", "Farm [F]");
tip = Localization::instance().get("ui.unit.tip_farm_queued", "Build Farm (Queued - Click to plan target)");
} else {
label = Localization::instance().get("ui.unit.btn_build_farm", "Farm [F]");
tip = Localization::instance().get("ui.unit.tip_build_farm", "Build Farm (F) - Click target tile (Shift+Click to queue)");
}
} else if (act == "build_road" || act == "weg_bouwen") {
isMode = (GameManager::instance().currentActionMode() == ActionMode::BuildRoad);
bool isBuildingRoad = unit->isBuilding() && unit->buildingImprovementId.startsWith("road");
bool hasRoute = unit->hasRoadRoute();
bool isQueued = (unit->currentAction.type == UnitActionType::BuildRoad) || unit->hasQueuedActionType(UnitActionType::BuildRoad);
isActive = isMode || isBuildingRoad || hasRoute || isQueued;
iconFile = "images/actions/road.png";
if (isBuildingRoad) {
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% (Click to plan next target)").arg(pct);
} else if (hasRoute) {
int cur = (int)unit->roadRouteIndex + 1;
int total = (int)unit->roadRoute.size();
label = Localization::instance().get("ui.unit.btn_road_route", "Route (%1/%2)").arg(cur).arg(total);
tip = Localization::instance().get("ui.unit.status_road_route", "Road Route: %1/%2").arg(cur).arg(total);
} else if (isQueued) {
label = Localization::instance().get("ui.unit.btn_road_queued", "Road [R]");
tip = Localization::instance().get("ui.unit.tip_road_queued", "Build Road (Queued - Click to plan target)");
} else {
label = Localization::instance().get("ui.unit.btn_build_road", "Road [R]");
tip = Localization::instance().get("ui.unit.tip_build_road", "Build Road (R) - Click target tile (Shift+Click to queue)");
}
}
// Button styling
painter.setBrush(isMode ? QColor(55, 105, 175, 250) : (isActive ? QColor(45, 85, 145, 240) : QColor(24, 32, 46, 220)));
painter.setPen(QPen(isMode ? QColor(100, 235, 255) : (isActive ? QColor(255, 220, 50) : QColor(80, 110, 150)), (isMode || isActive) ? 1.8 : 1.0));
painter.drawRoundedRect(bRect, 6, 6);
// Icon inside button
QImage aIcon = getTintedIcon(iconFile, (isMode || 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((isMode || 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::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_cityQueueItemHits.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 panelW = 340;
int panelX = width() - panelW - 14;
int availableCardW = std::max(360, panelX - 28);
int cardW = std::min(600, availableCardW);
int cardH = 174;
int cardX = 14 + (panelX - 14 - cardW) / 2;
QRect cardRect(cardX, 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 & Queue Vertical Panel (Right Side)
int panelH = height() - 28;
int panelY = 14;
QRect prodPanelRect(panelX, panelY, panelW, panelH);
painter.setBrush(QColor(16, 22, 32, 248));
painter.setPen(QPen(primaryCol, 1.8));
painter.drawRoundedRect(prodPanelRect, 10, 10);
int curY = prodPanelRect.top() + 10;
int innerX = prodPanelRect.left() + 12;
int innerW = prodPanelRect.width() - 24;
const CityConfig &cityCfg = TerrainYieldManager::instance().getCityConfig();
int maxQueue = cityCfg.maxProductionQueueSize;
// Header: City Production Title
painter.setFont(QFont("sans-serif", 9, QFont::Bold));
painter.setPen(QColor(230, 240, 255));
painter.drawText(QRect(innerX, curY, innerW - 80, 18),
Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.city.production_panel_title", "CITY PRODUCTION"));
// Queue count indicator
int totalQueuedCount = (int)city->projectQueue.size() + (city->hasActiveProject() ? 1 : 0);
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(totalQueuedCount > maxQueue ? QColor(255, 120, 120) : QColor(140, 185, 225));
painter.drawText(QRect(innerX + innerW - 80, curY, 80, 18),
Qt::AlignRight | Qt::AlignVCenter,
QString("%1 / %2").arg(totalQueuedCount).arg(maxQueue + 1));
curY += 22;
// Section 1: Active Production Header
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(220, 200, 140));
painter.drawText(QRect(innerX, curY, innerW, 16),
Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.city.current_production", "CURRENT PRODUCTION"));
curY += 18;
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);
QRect activeBox(innerX, curY, innerW, 82);
painter.setBrush(QColor(24, 34, 48, 220));
painter.setPen(QPen(QColor(255, 215, 60, 180), 1.2));
painter.drawRoundedRect(activeBox, 6, 6);
// Project Icon
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(activeBox.left() + 8, activeBox.top() + 8, 26, 26), pIcon);
// Name
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(255, 235, 120));
painter.drawText(QRect(activeBox.left() + 38, activeBox.top() + 6, activeBox.width() - 105, 16),
Qt::AlignLeft | Qt::AlignVCenter, proj.name);
// District / Type
painter.setFont(QFont("sans-serif", 7));
painter.setPen(QColor(170, 195, 220));
QString typeDesc = (proj.type == CityProjectType::Unit)
? Localization::instance().get("ui.city.tab_units", "Unit")
: QString("Building (%1)").arg(proj.district);
painter.drawText(QRect(activeBox.left() + 38, activeBox.top() + 21, activeBox.width() - 105, 13),
Qt::AlignLeft | Qt::AlignVCenter, typeDesc);
// Cancel button
m_cancelProjectBtnRect = QRect(activeBox.right() - 62, activeBox.top() + 7, 56, 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(activeBox.left() + 8, activeBox.top() + 40, activeBox.width() - 16, 15);
painter.setBrush(QColor(18, 25, 36));
painter.setPen(QPen(QColor(55, 80, 115), 1.0));
painter.drawRoundedRect(barBg, 3, 3);
QRect barFg(activeBox.left() + 8, activeBox.top() + 40, (int)((activeBox.width() - 16) * progress), 15);
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(activeBox.left() + 8, activeBox.top() + 59, activeBox.width() - 16, 14),
Qt::AlignLeft | Qt::AlignVCenter,
QString("~%1 %2 remaining (+%3 Labor/tick)")
.arg(estTicks).arg(estTicks == 1 ? "tick" : "ticks").arg(laborRate));
curY += 88;
} else {
// No active project
QRect noActiveBox(innerX, curY, innerW, 44);
painter.setBrush(QColor(20, 26, 36, 180));
painter.setPen(QPen(QColor(50, 65, 85), 1.0));
painter.drawRoundedRect(noActiveBox, 6, 6);
painter.setFont(QFont("sans-serif", 8, QFont::StyleItalic));
painter.setPen(QColor(180, 195, 210));
painter.drawText(QRect(noActiveBox.left() + 10, noActiveBox.top() + 6, noActiveBox.width() - 20, 16),
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(noActiveBox.left() + 10, noActiveBox.top() + 22, noActiveBox.width() - 20, 16),
Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.city.select_prompt", "Select an item below to construct"));
curY += 50;
}
// Section 2: Queue Header
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(180, 205, 235));
QString qHeader = Localization::instance().get("ui.city.queue_header", "QUEUE (%1 / %2)")
.arg(city->projectQueue.size()).arg(maxQueue);
painter.drawText(QRect(innerX, curY, innerW, 16), Qt::AlignLeft | Qt::AlignVCenter, qHeader);
curY += 18;
if (city->projectQueue.empty()) {
QRect emptyQBox(innerX, curY, innerW, 26);
painter.setBrush(QColor(18, 24, 34, 140));
painter.setPen(QPen(QColor(40, 55, 75, 100), 1.0, Qt::DashLine));
painter.drawRoundedRect(emptyQBox, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::StyleItalic));
painter.setPen(QColor(130, 150, 175));
painter.drawText(emptyQBox, Qt::AlignCenter,
Localization::instance().get("ui.city.queue_empty", "Queue is empty"));
curY += 32;
} else {
for (size_t qi = 0; qi < city->projectQueue.size(); ++qi) {
const CityProject &qProj = city->projectQueue[qi];
QRect qRow(innerX, curY, innerW, 28);
painter.setBrush(QColor(22, 30, 42, 210));
painter.setPen(QPen(QColor(50, 70, 95), 1.0));
painter.drawRoundedRect(qRow, 4, 4);
// Badge #1, #2
QRect numBadge(qRow.left() + 4, qRow.top() + 4, 22, 20);
painter.setBrush(QColor(40, 60, 90));
painter.setPen(Qt::NoPen);
painter.drawRoundedRect(numBadge, 3, 3);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(180, 220, 255));
painter.drawText(numBadge, Qt::AlignCenter, QString("#%1").arg(qi + 1));
// Icon
QString qIconPath = "images/improvements/granary.png";
if (qProj.type == CityProjectType::Unit) {
const UnitConfig *uCfg = GameManager::instance().unitConfigs().getConfig(qProj.id);
if (uCfg && !uCfg->iconPath.isEmpty()) qIconPath = uCfg->iconPath;
} else if (qProj.type == CityProjectType::Building) {
const BuildingConfig *bCfg = GameManager::instance().buildingConfigs().getBuilding(qProj.id);
if (bCfg && !bCfg->iconPath.isEmpty()) qIconPath = bCfg->iconPath;
}
QImage qIcon = getTintedIcon(qIconPath, QColor(255, 215, 60));
painter.drawImage(QRect(qRow.left() + 30, qRow.top() + 5, 18, 18), qIcon);
// Name
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(230, 240, 255));
painter.drawText(QRect(qRow.left() + 52, qRow.top() + 2, innerW - 138, 24),
Qt::AlignLeft | Qt::AlignVCenter, qProj.name);
// Labor Cost
painter.setFont(QFont("sans-serif", 7));
painter.setPen(QColor(240, 160, 50));
painter.drawText(QRect(qRow.right() - 84, qRow.top() + 2, 54, 24),
Qt::AlignRight | Qt::AlignVCenter, QString("%1 L").arg(qProj.laborCost));
// Remove Button [X]
QRect remRect(qRow.right() - 24, qRow.top() + 4, 20, 20);
painter.setBrush(QColor(50, 25, 30, 200));
painter.setPen(QPen(QColor(200, 80, 80), 1.0));
painter.drawRoundedRect(remRect, 3, 3);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(255, 140, 140));
painter.drawText(remRect, Qt::AlignCenter, "X");
m_cityQueueItemHits.push_back({remRect, (int)qi});
curY += 32;
}
}
// Section 3: Districts Header
painter.setPen(QPen(QColor(60, 85, 120, 150), 1.0));
painter.drawLine(innerX, curY + 2, innerX + innerW, curY + 2);
curY += 8;
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(220, 200, 140));
painter.drawText(QRect(innerX, curY, innerW, 16),
Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.city.districts_header", "DISTRICT: CITY CENTER"));
curY += 18;
if (!city->completedBuildings.empty()) {
for (const QString &bId : city->completedBuildings) {
const BuildingConfig *bCfg = GameManager::instance().buildingConfigs().getBuilding(bId);
if (!bCfg) continue;
QRect bBadge(innerX, curY, innerW, 28);
painter.setBrush(QColor(22, 38, 28, 210));
painter.setPen(QPen(QColor(80, 220, 120), 1.0));
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() + 5, 18, 18), bIcon);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(210, 255, 220));
painter.drawText(QRect(bBadge.left() + 28, bBadge.top() + 2, 100, 24),
Qt::AlignLeft | Qt::AlignVCenter, bCfg->name);
painter.setFont(QFont("sans-serif", 7));
painter.setPen(QColor(160, 230, 180));
painter.drawText(QRect(bBadge.left() + 132, bBadge.top() + 2, bBadge.width() - 136, 24),
Qt::AlignLeft | Qt::AlignVCenter, bCfg->description);
curY += 32;
}
} else {
painter.setFont(QFont("sans-serif", 7, QFont::StyleItalic));
painter.setPen(QColor(140, 160, 180));
painter.drawText(QRect(innerX, curY, innerW, 16),
Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.city.no_buildings", "No extensions built yet"));
curY += 20;
}
// Section 4: Projects Section Divider & Tabs
painter.setPen(QPen(QColor(60, 85, 120, 150), 1.0));
painter.drawLine(innerX, curY + 2, innerX + innerW, curY + 2);
curY += 8;
// Tabs: Buildings & Units
int tabW = (innerW - 8) / 2;
int tabH = 22;
m_tabBuildingsRect = QRect(innerX, curY, tabW, tabH);
m_tabUnitsRect = QRect(m_tabBuildingsRect.right() + 8, curY, tabW, tabH);
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"));
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"));
curY += 28;
// List of available projects under selected tab
int itemH = 50;
if (m_cityProjectTab == 0) {
// Buildings tab (only show unlocked, non-obsolete buildings)
const auto &buildings = GameManager::instance().buildingConfigs().getAllBuildings();
for (auto it = buildings.begin(); it != buildings.end(); ++it) {
if (curY + itemH > prodPanelRect.bottom() - 6) break;
const BuildingConfig &bCfg = it.value();
if (player) {
if (!player->isItemUnlocked(bCfg.requiredTech)) continue;
if (player->isItemObsolete(bCfg.obsoleteTech)) continue;
}
QRect itemRect(innerX, curY, innerW, itemH);
bool alreadyBuilt = city->hasBuilding(bCfg.id);
bool isActiveProj = (city->hasActiveProject() && city->currentProject.type == CityProjectType::Building && city->currentProject.id == bCfg.id);
int qPos = city->getQueuedBuildingPosition(bCfg.id);
bool isQueued = (qPos > 0);
bool isQueueFull = ((int)city->projectQueue.size() >= maxQueue && city->hasActiveProject());
painter.setBrush(isActiveProj ? QColor(35, 50, 75, 230)
: (isQueued ? QColor(26, 40, 58, 220)
: (alreadyBuilt ? QColor(20, 25, 32, 190) : QColor(24, 32, 46, 220))));
painter.setPen(QPen(isActiveProj ? QColor(255, 220, 50)
: (isQueued ? QColor(100, 190, 255)
: (alreadyBuilt ? QColor(60, 75, 95) : QColor(70, 100, 140))),
(isActiveProj || isQueued) ? 1.5 : 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() + 6, itemRect.top() + 7, 24, 24), bIcon);
// 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() + 36, itemRect.top() + 4, itemRect.width() - 105, 15),
Qt::AlignLeft | Qt::AlignVCenter, bCfg.name);
// Cost & estimated ticks
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() + 36, itemRect.top() + 19, itemRect.width() - 105, 13),
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() + 36, itemRect.top() + 32, itemRect.width() - 40, 14),
Qt::AlignLeft | Qt::AlignVCenter, bCfg.description);
// Action Button / Status badge on right side
QRect btnRect(itemRect.right() - 66, itemRect.top() + 12, 60, 26);
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 if (isQueued) {
painter.setBrush(QColor(30, 50, 75, 220));
painter.setPen(QPen(QColor(80, 180, 255), 1.2));
painter.drawRoundedRect(btnRect, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(140, 215, 255));
painter.drawText(btnRect, Qt::AlignCenter,
Localization::instance().get("ui.city.status_queued", "QUEUED #%1").arg(qPos));
} else if (isQueueFull) {
painter.setBrush(QColor(40, 30, 35, 180));
painter.setPen(QPen(QColor(120, 70, 75), 1.0));
painter.drawRoundedRect(btnRect, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(180, 130, 135));
painter.drawText(btnRect, Qt::AlignCenter,
Localization::instance().get("ui.city.status_full", "FULL"));
} else {
bool hasAct = city->hasActiveProject();
painter.setBrush(hasAct ? QColor(25, 55, 90, 240) : QColor(30, 60, 100, 240));
painter.setPen(QPen(hasAct ? QColor(60, 175, 245) : QColor(80, 160, 255), 1.2));
painter.drawRoundedRect(btnRect, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(Qt::white);
QString btnLabel = hasAct
? Localization::instance().get("ui.city.btn_queue", "+ Queue")
: Localization::instance().get("ui.city.btn_build", "Build");
painter.drawText(btnRect, Qt::AlignCenter, btnLabel);
m_cityProjectHits.push_back({btnRect, CityProjectType::Building, bCfg.id});
}
curY += itemH + 6;
}
} else {
// Units tab (only show unlocked, non-obsolete units)
const auto &units = GameManager::instance().unitConfigs().getAllConfigs();
for (auto it = units.begin(); it != units.end(); ++it) {
if (curY + itemH > prodPanelRect.bottom() - 6) break;
const UnitConfig &uCfg = it.value();
if (player) {
if (!player->isItemUnlocked(uCfg.requiredTech)) continue;
if (player->isItemObsolete(uCfg.obsoleteTech)) continue;
}
QRect itemRect(innerX, curY, innerW, itemH);
bool isActiveProj = (city->hasActiveProject() && city->currentProject.type == CityProjectType::Unit && city->currentProject.id == uCfg.id);
int qCount = city->getQueuedUnitCount(uCfg.id);
bool isQueueFull = ((int)city->projectQueue.size() >= maxQueue && city->hasActiveProject());
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.5 : 1.0));
painter.drawRoundedRect(itemRect, 6, 6);
// Icon
QImage uIcon = getTintedIcon(uCfg.iconPath, primaryCol);
painter.drawImage(QRect(itemRect.left() + 6, itemRect.top() + 7, 24, 24), uIcon);
// Name + Queued Count
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(245, 250, 255));
QString uName = uCfg.name;
if (qCount > 0) {
uName += QString(" (%1 queued)").arg(qCount);
}
painter.drawText(QRect(itemRect.left() + 36, itemRect.top() + 4, itemRect.width() - 105, 15),
Qt::AlignLeft | Qt::AlignVCenter, uName);
// 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() + 36, itemRect.top() + 19, itemRect.width() - 105, 13),
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() + 36, itemRect.top() + 32, itemRect.width() - 40, 14),
Qt::AlignLeft | Qt::AlignVCenter, infoStr);
// Action Button on right side
QRect btnRect(itemRect.right() - 66, itemRect.top() + 12, 60, 26);
if (isQueueFull) {
painter.setBrush(QColor(40, 30, 35, 180));
painter.setPen(QPen(QColor(120, 70, 75), 1.0));
painter.drawRoundedRect(btnRect, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(180, 130, 135));
painter.drawText(btnRect, Qt::AlignCenter,
Localization::instance().get("ui.city.status_full", "FULL"));
} else {
bool hasAct = city->hasActiveProject();
painter.setBrush(hasAct ? QColor(25, 55, 90, 240) : QColor(30, 60, 100, 240));
painter.setPen(QPen(hasAct ? QColor(60, 175, 245) : QColor(80, 160, 255), 1.2));
painter.drawRoundedRect(btnRect, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(Qt::white);
QString btnLabel = hasAct
? Localization::instance().get("ui.city.btn_queue", "+ Queue")
: Localization::instance().get("ui.city.btn_train", "Train");
painter.drawText(btnRect, Qt::AlignCenter, btnLabel);
m_cityProjectHits.push_back({btnRect, CityProjectType::Unit, uCfg.id});
}
curY += itemH + 6;
}
}
// 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 = QStringList{"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 active project
if (city && m_cancelProjectBtnRect.contains(event->pos())) {
GameManager::instance().cancelCityProject(city->id);
update();
return;
}
// Cancel individual queued project item
if (city) {
for (const auto &qHit : m_cityQueueItemHits) {
if (qHit.rect.contains(event->pos())) {
GameManager::instance().removeQueuedCityProject(city->id, qHit.queueIndex);
update();
return;
}
}
}
// Project selection clicks
if (city) {
for (const auto &pHit : m_cityProjectHits) {
if (pHit.rect.contains(event->pos())) {
GameManager::instance().queueCityProject(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
}
// 0b. If Tech Tree is open: handle Tech Tree interactions
if (GameManager::instance().isTechTreeOpen()) {
if (m_closeTechTreeBtnRect.contains(event->pos()) || m_techTreeBtnRect.contains(event->pos())) {
m_draggingTechTree = false;
GameManager::instance().closeTechTree();
update();
return;
}
if (m_clearTechQueueBtnRect.contains(event->pos()) && event->button() == Qt::LeftButton) {
GameManager::instance().clearPlayerResearchQueue(1);
update();
return;
}
// Check queue chips in header
for (const auto &chip : m_techQueueChipHits) {
if (chip.rect.contains(event->pos())) {
if (event->button() == Qt::RightButton || event->button() == Qt::LeftButton) {
GameManager::instance().removeTechFromPlayerQueue(1, chip.techId);
update();
}
return;
}
}
// Start potential drag or click inside tech tree canvas
if (m_techTreeCanvasRect.contains(event->pos())) {
m_draggingTechTree = true;
m_techTreeDragMoved = false;
m_techTreeDragStartPos = event->pos();
lastPos = event->pos();
return;
}
return; // Consume clicks while Tech Tree modal is open
}
// 0c. Top bar Tech Tree button or Science slot click
if ((m_techTreeBtnRect.contains(event->pos()) || m_scienceBarHitRect.contains(event->pos())) && event->button() == Qt::LeftButton) {
GameManager::instance().toggleTechTree();
update();
return;
}
// Action Queue items (Bottom HUD notification bar)
for (const auto &hit : m_actionItemHits) {
if (hit.dismissRect.contains(event->pos()) && event->button() == Qt::LeftButton) {
GameManager::instance().actionNeededManager().dismissItem(hit.type, hit.entityId);
update();
return;
}
if (hit.rect.contains(event->pos())) {
if (event->button() == Qt::RightButton) {
GameManager::instance().actionNeededManager().dismissItem(hit.type, hit.entityId);
update();
return;
} else if (event->button() == Qt::LeftButton) {
handleActionItemClick(hit.type, hit.entityId);
update();
return;
}
}
}
if (m_actionQueueHeaderRect.contains(event->pos())) {
return;
}
// 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 (all unit actions enter target-tile selection mode)
for (const auto &ab : m_activeActionBubbles) {
if (ab.rect.contains(event->pos())) {
if (ab.actionId == "verplaatsen") {
GameManager::instance().setActionMode(
GameManager::instance().currentActionMode() == ActionMode::Move ? ActionMode::None : ActionMode::Move
);
} else if (ab.actionId == "aanvallen") {
GameManager::instance().setActionMode(
GameManager::instance().currentActionMode() == ActionMode::Attack ? ActionMode::None : ActionMode::Attack
);
} else if (ab.actionId == "steden_bouwen" || ab.actionId == "build_city") {
GameManager::instance().setActionMode(
GameManager::instance().currentActionMode() == ActionMode::BuildCity ? ActionMode::None : ActionMode::BuildCity
);
} else if (ab.actionId == "boerderij_bouwen" || ab.actionId == "build_farm") {
GameManager::instance().setActionMode(
GameManager::instance().currentActionMode() == ActionMode::BuildFarm ? ActionMode::None : ActionMode::BuildFarm
);
} else if (ab.actionId == "build_road" || ab.actionId == "weg_bouwen") {
GameManager::instance().setActionMode(
GameManager::instance().currentActionMode() == ActionMode::BuildRoad ? ActionMode::None : ActionMode::BuildRoad
);
}
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. Check if directly clicked on any unit's Druppel pin
Unit *clickedPinUnit = nullptr;
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) {
clickedPinUnit = u.get();
break;
}
}
// 6. Terrain hex picking via Raycast
int hitX = -1, hitY = -1;
bool hitTerrain = screenToHex(event->pos(), vMatrix, hitX, hitY);
if (!hitTerrain && clickedPinUnit) {
hitX = clickedPinUnit->tileX;
hitY = clickedPinUnit->tileY;
hitTerrain = true;
}
Unit *selected = GameManager::instance().getSelectedUnit();
bool shiftHeld = (event->modifiers() & Qt::ShiftModifier) != 0;
if (event->button() == Qt::LeftButton) {
// When an ActionMode is active for the selected unit, clicking selects the target tile
if (selected && GameManager::instance().currentActionMode() != ActionMode::None) {
ActionMode mode = GameManager::instance().currentActionMode();
if (mode == ActionMode::Attack) {
Unit *enemy = (clickedPinUnit && clickedPinUnit->playerId != selected->playerId)
? clickedPinUnit
: (hitTerrain ? GameManager::instance().getUnitAt(hitX, hitY) : nullptr);
if (enemy && enemy->playerId != selected->playerId) {
bool ok = GameManager::instance().commandAttack(enemy, m_point, m_numPoints05, shiftHeld);
if (ok && !shiftHeld) {
GameManager::instance().setActionMode(ActionMode::None);
}
} else if (hitTerrain) {
QVector2D w = Pathfinder::hexToWorld(hitX, hitY);
float gz = Unit::getTerrainHeight(w.x(), w.y(), m_point, m_numPoints05);
GameManager::instance().addFloatingText(
Localization::instance().get("ui.unit.no_enemy_on_tile", "No enemy on target tile!"),
QVector3D(w.x(), w.y(), gz + 0.8f), QColor(255, 80, 80));
}
update();
return;
} else if (hitTerrain) {
bool ok = false;
if (mode == ActionMode::Move) {
ok = GameManager::instance().commandMoveSelected(hitX, hitY, m_point, m_numPoints05, shiftHeld);
} else if (mode == ActionMode::BuildFarm) {
ok = GameManager::instance().commandBuildFarmAtTileSelected(hitX, hitY, m_point, m_numPoints05, shiftHeld);
} else if (mode == ActionMode::BuildCity) {
ok = GameManager::instance().commandBuildCityAtTileSelected(hitX, hitY, m_point, m_numPoints05, shiftHeld);
} else if (mode == ActionMode::BuildRoad) {
ok = GameManager::instance().commandBuildRoadToTileSelected(hitX, hitY, m_point, m_numPoints05, shiftHeld);
}
// Keep ActionMode active if Shift is held so the player can keep chaining from the previous target tile
if (ok && !shiftHeld) {
GameManager::instance().setActionMode(ActionMode::None);
}
update();
return;
}
}
// Standard unit selection when no ActionMode is active
if (clickedPinUnit) {
GameManager::instance().selectUnit(clickedPinUnit->id);
update();
return;
}
if (hitTerrain) {
// 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) {
if (GameManager::instance().currentActionMode() != ActionMode::None) {
GameManager::instance().setActionMode(ActionMode::None);
update();
return;
}
// Right click: Attack enemy unit or move selected unit to target hex (supports Shift-queueing)
if (selected && hitTerrain) {
Unit *enemy = (clickedPinUnit && clickedPinUnit->playerId != selected->playerId)
? clickedPinUnit
: GameManager::instance().getUnitAt(hitX, hitY);
if (enemy && enemy->playerId != selected->playerId) {
GameManager::instance().commandAttack(enemy, m_point, m_numPoints05, shiftHeld);
} else {
GameManager::instance().commandMoveSelected(hitX, hitY, m_point, m_numPoints05, shiftHeld);
}
update();
return;
}
}
lastPos = event->pos();
}
void QSceneRenderer::mouseMoveEvent(QMouseEvent *event)
{
m_mouseCurrentPos = event->pos();
if (GameManager::instance().isTechTreeOpen()) {
if (m_draggingTechTree && (event->buttons() & (Qt::LeftButton | Qt::MiddleButton | Qt::RightButton))) {
int dx = event->x() - lastPos.x();
int dy = event->y() - lastPos.y();
if ((event->pos() - m_techTreeDragStartPos).manhattanLength() > 6) {
m_techTreeDragMoved = true;
}
m_techTreeScrollX = std::max(0, m_techTreeScrollX - dx);
m_techTreeScrollY = std::max(0, m_techTreeScrollY - dy);
lastPos = event->pos();
}
update();
return;
}
// 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();
}
bool onActionItem = false;
for (const auto &hit : m_actionItemHits) {
if (hit.rect.contains(event->pos())) {
onActionItem = true;
break;
}
}
if (onActionItem || m_techTreeBtnRect.contains(event->pos()) || m_scienceBarHitRect.contains(event->pos())) {
setCursor(Qt::PointingHandCursor);
} else {
unsetCursor();
}
lastPos = event->pos();
}
void QSceneRenderer::mouseReleaseEvent(QMouseEvent *event)
{
m_draggingMinimap = false;
if (GameManager::instance().isTechTreeOpen()) {
if (m_draggingTechTree && !m_techTreeDragMoved) {
// Clicked (without dragging) inside the Tech Tree canvas
for (const auto &nodeHit : m_techNodeHits) {
if (nodeHit.rect.contains(event->pos())) {
if (event->button() == Qt::LeftButton) {
bool append = (event->modifiers() & Qt::ShiftModifier);
GameManager::instance().selectTechForPlayer(1, nodeHit.techId, append);
} else if (event->button() == Qt::RightButton) {
GameManager::instance().removeTechFromPlayerQueue(1, nodeHit.techId);
}
break;
}
}
}
m_draggingTechTree = false;
m_techTreeDragMoved = false;
update();
return;
}
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)
{
if (e->key() == Qt::Key_T && !GameManager::instance().isInCityView()) {
GameManager::instance().toggleTechTree();
update();
return;
}
if (GameManager::instance().isTechTreeOpen()) {
if (e->key() == Qt::Key_Escape) {
GameManager::instance().closeTechTree();
update();
}
return;
}
// 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 (all toggle target-tile selection mode)
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().setActionMode(
GameManager::instance().currentActionMode() == ActionMode::BuildCity ? ActionMode::None : ActionMode::BuildCity
);
}
else if (e->key() == Qt::Key_F && sel && sel->config.canBuildFarm()) {
GameManager::instance().setActionMode(
GameManager::instance().currentActionMode() == ActionMode::BuildFarm ? ActionMode::None : ActionMode::BuildFarm
);
}
else if (e->key() == Qt::Key_R && sel && sel->config.canBuildRoad()) {
GameManager::instance().setActionMode(
GameManager::instance().currentActionMode() == ActionMode::BuildRoad ? ActionMode::None : ActionMode::BuildRoad
);
}
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 if (sel && sel->hasRoadRoute()) {
sel->clearRoadRoute();
sel->cancelBuilding();
sel->currentAction = UnitAction();
GameManager::instance().addFloatingText(
Localization::instance().get("ui.unit.road_route_cancelled", "Road route cancelled"),
sel->worldPos + QVector3D(0, 0, 0.8f), QColor(255, 120, 100));
} 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)
{
if (GameManager::instance().isTechTreeOpen()) {
if (e->modifiers() & Qt::ShiftModifier) {
m_techTreeScrollY = std::max(0, m_techTreeScrollY - e->delta() / 2);
} else {
m_techTreeScrollX = std::max(0, m_techTreeScrollX - e->delta() / 2);
}
update();
return;
}
// 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 & Cities (Cylinders & Bastions; roads are rendered directly in the tile shader texture)
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
);
// 4. Render 2D UI & HUD Overlays via QPainter
QPainter painter(this);
drawCityBorders(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);
}
drawActionNeededQueue(painter);
if (GameManager::instance().isTechTreeOpen()) {
drawTechTreeOverlay(painter);
}
} else {
drawUnitsOverlay(painter, vMatrix);
drawFloatingTexts(painter, vMatrix);
drawCityViewOverlay(painter, vMatrix);
}
painter.end();
}
void QSceneRenderer::drawActionNeededQueue(QPainter &painter)
{
m_actionItemHits.clear();
m_actionQueueHeaderRect = QRect();
const ActionNeededConfig &cfg = GameManager::instance().actionNeededManager().config();
if (!cfg.enabled) return;
std::vector<ActionItem> visibleItems = GameManager::instance().actionNeededManager().getVisibleItems();
if (visibleItems.empty()) return;
painter.setRenderHint(QPainter::Antialiasing, true);
QRect minimapCard = getMinimapCardRect();
int minLeft = minimapCard.right() + 14;
Unit *selected = GameManager::instance().getSelectedUnit();
int maxRight = selected ? (m_unitCardRect.left() - 14) : (width() - 16);
int availableW = maxRight - minLeft;
if (availableW < 120) return;
int itemW = cfg.itemWidth;
int itemH = cfg.itemHeight;
int spacing = cfg.itemSpacing;
int headerW = 82;
int remainingForItems = availableW - headerW - spacing;
int maxItemsThatFit = std::max(1, remainingForItems / (itemW + spacing));
int numToRender = std::min((int)visibleItems.size(), std::min(cfg.maxVisibleItems, maxItemsThatFit));
bool hasOverflow = (int)visibleItems.size() > numToRender;
int overflowW = hasOverflow ? 54 : 0;
if (hasOverflow && remainingForItems < numToRender * (itemW + spacing) + overflowW) {
numToRender = std::max(1, numToRender - 1);
}
int totalW = headerW + spacing + numToRender * itemW + (numToRender - 1) * spacing + (hasOverflow ? (spacing + overflowW) : 0);
int startX = minLeft + (availableW - totalW) / 2;
if (startX < minLeft) startX = minLeft;
int bottomY = height() - itemH - cfg.bottomOffset;
// 1. Header badge
QRect headerRect(startX, bottomY, headerW, itemH);
m_actionQueueHeaderRect = headerRect;
painter.setBrush(cfg.cardBgColor);
painter.setPen(QPen(QColor(70, 95, 130, 180), 1.2));
painter.drawRoundedRect(headerRect, cfg.borderRadius, cfg.borderRadius);
float pulse = cfg.pulseAnimation ? (sin(GameManager::instance().actionNeededManager().getElapsedGameTime() * cfg.pulseSpeed) * 0.5f + 0.5f) : 1.0f;
QRect badgeRect(headerRect.left() + 8, headerRect.top() + (itemH - 20) / 2, 20, 20);
QColor badgeCol(255, 170, 40);
badgeCol.setAlpha((int)(170 + 80 * pulse));
painter.setBrush(badgeCol);
painter.setPen(Qt::NoPen);
painter.drawEllipse(badgeRect);
painter.setFont(QFont("sans-serif", 9, QFont::Bold));
painter.setPen(Qt::white);
painter.drawText(badgeRect, Qt::AlignCenter, QString::number(visibleItems.size()));
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(215, 230, 250));
QRect headerTextRect(badgeRect.right() + 6, headerRect.top(), headerRect.right() - badgeRect.right() - 8, itemH);
painter.drawText(headerTextRect, Qt::AlignLeft | Qt::AlignVCenter, Localization::instance().get("ui.action_needed.header_title", "ACTIONS"));
// 2. Render cards (oldest first)
for (int i = 0; i < numToRender; ++i) {
const ActionItem &item = visibleItems[i];
int itemX = startX + headerW + spacing + i * (itemW + spacing);
QRect itemRect(itemX, bottomY, itemW, itemH);
QRect dismissRect(itemRect.right() - 16, itemRect.top() + 3, 13, 13);
m_actionItemHits.push_back({itemRect, dismissRect, item.type, item.entityId});
bool isHovered = itemRect.contains(m_mouseCurrentPos);
QString title;
QString subtitle;
QColor accent;
QImage icon;
if (item.type == ActionNeededType::UnitIdle) {
Unit *u = GameManager::instance().getUnitById(item.entityId);
title = u ? u->config.name.toUpper() : Localization::instance().get("ui.unit.civilian_unit", "UNIT");
subtitle = Localization::instance().get("ui.action_needed.unit_idle_desc", "Needs orders");
accent = cfg.unitIdleColor;
const Player *p = u ? GameManager::instance().players().getPlayer(u->playerId) : nullptr;
QColor pCol = p ? p->primaryColor : Qt::blue;
icon = u ? getTintedIcon(u->config.iconPath, pCol) : QImage();
} else if (item.type == ActionNeededType::CityNoProduction) {
City *c = GameManager::instance().getCityById(item.entityId);
title = c ? c->name : Localization::instance().get("ui.city.center", "CITY");
subtitle = Localization::instance().get("ui.action_needed.city_no_production_desc", "No production");
accent = cfg.cityNoProdColor;
icon = getTintedIcon("images/resources/arbeid.png", accent);
} else if (item.type == ActionNeededType::CityFreePopulation) {
City *c = GameManager::instance().getCityById(item.entityId);
title = c ? c->name : Localization::instance().get("ui.city.center", "CITY");
subtitle = Localization::instance().get("ui.action_needed.city_free_pop_desc", "+%1 Free Pop").arg(item.auxValue);
accent = cfg.cityFreePopColor;
icon = getTintedIcon("images/resources/citizen.png", accent);
} else if (item.type == ActionNeededType::NoResearch) {
title = Localization::instance().get("ui.action_needed.no_research_title", "RESEARCH");
subtitle = Localization::instance().get("ui.action_needed.no_research_desc", "Choose technology");
accent = cfg.noResearchColor;
icon = getTintedIcon("images/resources/kennis.png", accent);
}
// Draw card background
QColor bg = cfg.cardBgColor;
if (isHovered) {
bg = bg.lighter(130);
}
painter.setBrush(bg);
painter.setPen(QPen(isHovered ? accent : QColor(70, 95, 130, 160), isHovered ? 1.5 : 1.0));
painter.drawRoundedRect(itemRect, cfg.borderRadius, cfg.borderRadius);
// Accent stripe on the left edge
QRect stripeRect(itemRect.left() + 2, itemRect.top() + 3, 3, itemH - 6);
painter.fillRect(stripeRect, accent);
// Icon badge
QRect iconRect(itemRect.left() + 9, itemRect.top() + (itemH - 24) / 2, 24, 24);
QColor iconBg = accent;
iconBg.setAlpha(40);
painter.setBrush(iconBg);
painter.setPen(Qt::NoPen);
painter.drawEllipse(iconRect);
if (!icon.isNull()) {
painter.drawImage(iconRect.adjusted(3, 3, -3, -3), icon);
}
// Title and subtitle text
int textLeft = iconRect.right() + 7;
int textW = dismissRect.left() - textLeft - 2;
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(240, 245, 255));
painter.drawText(QRect(textLeft, itemRect.top() + 4, textW, 16), Qt::AlignLeft | Qt::AlignVCenter, title);
painter.setFont(QFont("sans-serif", 7));
painter.setPen(accent);
painter.drawText(QRect(textLeft, itemRect.top() + 20, textW, 15), Qt::AlignLeft | Qt::AlignVCenter, subtitle);
// Dismiss [x]
bool isDismissHovered = dismissRect.contains(m_mouseCurrentPos);
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(isDismissHovered ? QColor(255, 120, 120) : QColor(130, 155, 180, 180));
painter.drawText(dismissRect, Qt::AlignCenter, "×");
}
// 3. Overflow indicator
if (hasOverflow) {
int moreCount = (int)visibleItems.size() - numToRender;
QRect ofRect(startX + headerW + spacing + numToRender * (itemW + spacing), bottomY, overflowW, itemH);
painter.setBrush(QColor(20, 26, 38, 220));
painter.setPen(QPen(QColor(70, 95, 130, 150), 1.0));
painter.drawRoundedRect(ofRect, cfg.borderRadius, cfg.borderRadius);
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(180, 210, 240));
QString ofText = Localization::instance().get("ui.action_needed.more_items", "+%1").arg(moreCount);
painter.drawText(ofRect, Qt::AlignCenter, ofText);
}
}
void QSceneRenderer::handleActionItemClick(ActionNeededType type, int entityId)
{
if (type == ActionNeededType::NoResearch) {
if (GameManager::instance().isInCityView()) {
GameManager::instance().exitCityView();
}
GameManager::instance().openTechTree();
return;
}
if (type == ActionNeededType::UnitIdle) {
if (GameManager::instance().isInCityView()) {
GameManager::instance().exitCityView();
}
if (GameManager::instance().isTechTreeOpen()) {
GameManager::instance().closeTechTree();
}
Unit *u = GameManager::instance().getUnitById(entityId);
if (u) {
GameManager::instance().selectUnit(entityId);
cameraPosition.setX(u->worldPos.x());
cameraPosition.setY(u->worldPos.y());
}
} else if (type == ActionNeededType::CityNoProduction || type == ActionNeededType::CityFreePopulation) {
City *c = GameManager::instance().getCityById(entityId);
if (c) {
if (!GameManager::instance().isInCityView()) {
m_savedCamPos = cameraPosition;
m_savedZoom = m_zoom;
m_savedZRot = zRot;
}
GameManager::instance().enterCityView(entityId);
cameraPosition.setX(c->worldPos.x());
cameraPosition.setY(c->worldPos.y());
if (type == ActionNeededType::CityNoProduction) {
m_cityProjectTab = 0;
}
}
}
}
void QSceneRenderer::drawTechTreeOverlay(QPainter &painter)
{
m_techNodeHits.clear();
m_techQueueChipHits.clear();
m_closeTechTreeBtnRect = QRect();
m_clearTechQueueBtnRect = QRect();
const Player *player = GameManager::instance().players().getPlayer(1);
if (!player) return;
painter.setRenderHint(QPainter::Antialiasing, true);
// Modal container below the top tick bar
QRect modalRect(16, 56, width() - 32, height() - 72);
painter.setBrush(QColor(12, 17, 26, 248));
painter.setPen(QPen(QColor(70, 140, 210, 220), 2.0));
painter.drawRoundedRect(modalRect, 10, 10);
int headerH = 84;
QRect headerRect(modalRect.left() + 14, modalRect.top() + 10, modalRect.width() - 28, headerH);
// 1. Title & Science per tick rate
painter.setFont(QFont("sans-serif", 13, QFont::Bold));
painter.setPen(QColor(235, 245, 255));
painter.drawText(QRect(headerRect.left(), headerRect.top(), 240, 26),
Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.tech_tree.title", "TECHNOLOGY TREE"));
int sciRate = GameManager::instance().calculatePlayerSciencePerTick(1, m_point, m_numPoints05);
QRect sciBadgeRect(headerRect.left() + 220, headerRect.top() + 2, 130, 24);
painter.setBrush(QColor(20, 40, 65, 230));
painter.setPen(QPen(QColor(60, 190, 255), 1.2));
painter.drawRoundedRect(sciBadgeRect, 5, 5);
QImage sciIcon = getTintedIcon("images/resources/kennis.png", QColor(60, 200, 255));
painter.drawImage(QRect(sciBadgeRect.left() + 6, sciBadgeRect.top() + 4, 16, 16), sciIcon);
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(90, 215, 255));
painter.drawText(QRect(sciBadgeRect.left() + 26, sciBadgeRect.top(), sciBadgeRect.width() - 30, 24),
Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.tech_tree.science_rate", "+%L1/tick").arg(sciRate));
// Close button [X]
m_closeTechTreeBtnRect = QRect(headerRect.right() - 88, headerRect.top() + 2, 88, 26);
painter.setBrush(QColor(45, 30, 40, 230));
painter.setPen(QPen(QColor(220, 110, 120), 1.3));
painter.drawRoundedRect(m_closeTechTreeBtnRect, 5, 5);
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(255, 210, 215));
painter.drawText(m_closeTechTreeBtnRect, Qt::AlignCenter,
Localization::instance().get("ui.tech_tree.close_btn", "[X] Close"));
// 2. Active Research & Queue Strip in Header
QRect queueStripRect(headerRect.left(), headerRect.top() + 34, headerRect.width(), 44);
painter.setBrush(QColor(18, 25, 38, 230));
painter.setPen(QPen(QColor(50, 80, 120, 160), 1.0));
painter.drawRoundedRect(queueStripRect, 6, 6);
if (player->researchQueue.empty()) {
painter.setFont(QFont("sans-serif", 8, QFont::StyleItalic));
painter.setPen(QColor(175, 195, 220));
painter.drawText(queueStripRect.adjusted(14, 0, -14, 0), Qt::AlignLeft | Qt::AlignVCenter,
Localization::instance().get("ui.tech_tree.no_active_research",
"No active research - Click any technology node below to start researching"));
} else {
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(160, 200, 240));
QString qLabel = Localization::instance().get("ui.tech_tree.queue_label", "QUEUE (%1):")
.arg(player->researchQueue.size());
painter.drawText(QRect(queueStripRect.left() + 10, queueStripRect.top(), 85, queueStripRect.height()),
Qt::AlignLeft | Qt::AlignVCenter, qLabel);
int chipX = queueStripRect.left() + 96;
int maxChipRight = queueStripRect.right() - 100;
for (size_t qi = 0; qi < player->researchQueue.size(); ++qi) {
QString qTechId = player->researchQueue[qi];
const TechConfig *qCfg = GameManager::instance().techTree().getTech(qTechId);
if (!qCfg) continue;
int prog = player->getTechProgress(qTechId);
int rem = std::max(0, qCfg->scienceCost - prog);
int pct = (int)(100.0f * (float)prog / (float)std::max(1, qCfg->scienceCost));
QString chipText = QString("#%1 %2 (%3% | %L4 left)").arg(qi + 1).arg(qCfg->name).arg(pct).arg(rem);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
int chipW = painter.fontMetrics().horizontalAdvance(chipText) + 18;
if (chipX + chipW > maxChipRight) break;
QRect chipRect(chipX, queueStripRect.top() + 8, chipW, 28);
bool isFirst = (qi == 0);
painter.setBrush(isFirst ? QColor(28, 65, 105, 245) : QColor(24, 38, 60, 230));
painter.setPen(QPen(isFirst ? QColor(80, 215, 255) : QColor(70, 120, 180), isFirst ? 1.5 : 1.0));
painter.drawRoundedRect(chipRect, 4, 4);
painter.setPen(isFirst ? QColor(230, 250, 255) : QColor(190, 215, 245));
painter.drawText(chipRect, Qt::AlignCenter, chipText);
m_techQueueChipHits.push_back({chipRect, qTechId});
chipX += chipW + 8;
}
// Clear Queue Button
m_clearTechQueueBtnRect = QRect(queueStripRect.right() - 90, queueStripRect.top() + 8, 82, 28);
painter.setBrush(QColor(55, 28, 34, 225));
painter.setPen(QPen(QColor(210, 90, 95), 1.0));
painter.drawRoundedRect(m_clearTechQueueBtnRect, 4, 4);
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(255, 165, 170));
painter.drawText(m_clearTechQueueBtnRect, Qt::AlignCenter,
Localization::instance().get("ui.tech_tree.clear_queue_btn", "Clear Queue"));
}
// 3. Tech Tree Canvas (Scrollable Grid of Technology Cards + Dependency Connectors)
int footerH = 26;
m_techTreeCanvasRect = QRect(modalRect.left() + 14,
headerRect.bottom() + 8,
modalRect.width() - 28,
modalRect.height() - headerH - footerH - 26);
painter.setBrush(QColor(9, 13, 20, 245));
painter.setPen(QPen(QColor(45, 70, 105, 180), 1.2));
painter.drawRoundedRect(m_techTreeCanvasRect, 8, 8);
// Layout constants for tech cards
const int nodeW = 236;
const int nodeH = 104;
const int colSpacing = 66;
const int rowSpacing = 22;
const int padX = 24;
const int padY = 20;
int maxCol = GameManager::instance().techTree().getMaxColumn();
int maxRow = GameManager::instance().techTree().getMaxRow();
int contentW = padX * 2 + (maxCol + 1) * nodeW + maxCol * colSpacing;
int contentH = padY * 2 + (maxRow + 1) * nodeH + maxRow * rowSpacing;
int maxScrollX = std::max(0, contentW - m_techTreeCanvasRect.width());
int maxScrollY = std::max(0, contentH - m_techTreeCanvasRect.height());
m_techTreeScrollX = std::max(0, std::min(m_techTreeScrollX, maxScrollX));
m_techTreeScrollY = std::max(0, std::min(m_techTreeScrollY, maxScrollY));
const auto &allTechs = GameManager::instance().techTree().getAllTechs();
const auto &orderedIds = GameManager::instance().techTree().getOrderedTechIds();
// Compute screen rectangles for all tech nodes
QMap<QString, QRect> nodeRects;
for (const QString &tId : orderedIds) {
const TechConfig &cfg = allTechs[tId];
int nx = m_techTreeCanvasRect.left() + padX + cfg.column * (nodeW + colSpacing) - m_techTreeScrollX;
int ny = m_techTreeCanvasRect.top() + padY + cfg.row * (nodeH + rowSpacing) - m_techTreeScrollY;
nodeRects[tId] = QRect(nx, ny, nodeW, nodeH);
}
painter.save();
painter.setClipRect(m_techTreeCanvasRect.adjusted(2, 2, -2, -2));
// Subtle column tier background stripes
for (int c = 0; c <= maxCol; ++c) {
int colLeft = m_techTreeCanvasRect.left() + padX + c * (nodeW + colSpacing) - m_techTreeScrollX - 10;
QRect colBand(colLeft, m_techTreeCanvasRect.top() + 4, nodeW + 20, m_techTreeCanvasRect.height() - 8);
if (c % 2 == 1) {
painter.fillRect(colBand, QColor(18, 26, 40, 65));
}
}
// 3a. Draw Prerequisite Connector Curves (from prereq right edge to dependent left edge)
for (const QString &tId : orderedIds) {
const TechConfig &cfg = allTechs[tId];
QRect targetR = nodeRects[tId];
bool targetResearched = player->hasResearched(tId);
bool targetQueued = player->isTechQueued(tId);
for (const QString &preId : cfg.prerequisites) {
if (!nodeRects.contains(preId)) continue;
QRect srcR = nodeRects[preId];
QPointF pStart(srcR.right(), srcR.center().y());
QPointF pEnd(targetR.left(), targetR.center().y());
float ctrlOffset = std::max(28.0f, (float)(pEnd.x() - pStart.x()) * 0.48f);
QPainterPath curve;
curve.moveTo(pStart);
curve.cubicTo(QPointF(pStart.x() + ctrlOffset, pStart.y()),
QPointF(pEnd.x() - ctrlOffset, pEnd.y()),
pEnd);
bool preResearched = player->hasResearched(preId);
bool preQueued = player->isTechQueued(preId);
QColor lineCol;
float lineW = 2.0f;
if (preResearched && targetResearched) {
lineCol = QColor(70, 215, 115, 220);
lineW = 2.5f;
} else if (preResearched) {
lineCol = QColor(80, 195, 255, 210);
lineW = 2.2f;
} else if (preQueued && targetQueued) {
lineCol = QColor(255, 210, 70, 210);
lineW = 2.2f;
} else {
lineCol = QColor(70, 95, 130, 150);
lineW = 1.6f;
}
painter.setPen(QPen(lineCol, lineW));
painter.setBrush(Qt::NoBrush);
painter.drawPath(curve);
// Arrowhead at pEnd
QPolygonF arrowHead;
arrowHead << pEnd
<< QPointF(pEnd.x() - 7.0f, pEnd.y() - 4.5f)
<< QPointF(pEnd.x() - 7.0f, pEnd.y() + 4.5f);
painter.setBrush(lineCol);
painter.setPen(Qt::NoPen);
painter.drawPolygon(arrowHead);
}
}
// 3b. Draw Technology Cards
int effectiveSciRate = std::max(1, sciRate);
for (const QString &tId : orderedIds) {
const TechConfig &cfg = allTechs[tId];
QRect nRect = nodeRects[tId];
if (!m_techTreeCanvasRect.intersects(nRect)) continue;
m_techNodeHits.push_back({nRect, tId});
bool isResearched = player->hasResearched(tId);
int qPos = player->getTechQueuePosition(tId);
bool isActive = (qPos == 1);
bool isQueued = (qPos > 1);
bool isAvailable = !isResearched && player->arePrerequisitesMet(cfg.prerequisites);
bool isHovered = nRect.contains(m_mouseCurrentPos);
int curProg = isResearched ? cfg.scienceCost : player->getTechProgress(tId);
int remSci = std::max(0, cfg.scienceCost - curProg);
float progFrac = std::max(0.0f, std::min(1.0f, (float)curProg / (float)std::max(1, cfg.scienceCost)));
int pct = (int)(progFrac * 100.0f);
int estTicks = (int)std::ceil((float)remSci / (float)effectiveSciRate);
// Card background & border based on state
QColor bgCol, borderCol, badgeBgCol, badgeTextCol;
QString statusText;
if (isResearched) {
bgCol = QColor(20, 42, 30, 240);
borderCol = QColor(75, 220, 120);
badgeBgCol = QColor(30, 75, 45);
badgeTextCol = QColor(120, 255, 155);
statusText = Localization::instance().get("ui.tech_tree.status_researched", "RESEARCHED");
} else if (isActive) {
bgCol = QColor(22, 46, 74, 245);
borderCol = QColor(80, 225, 255);
badgeBgCol = QColor(35, 95, 140);
badgeTextCol = QColor(160, 245, 255);
statusText = Localization::instance().get("ui.tech_tree.status_researching", "ACTIVE #1");
} else if (isQueued) {
bgCol = QColor(24, 38, 62, 240);
borderCol = QColor(255, 205, 65);
badgeBgCol = QColor(75, 60, 20);
badgeTextCol = QColor(255, 225, 100);
statusText = Localization::instance().get("ui.tech_tree.status_queued", "QUEUED #%1").arg(qPos);
} else if (isAvailable) {
bgCol = QColor(22, 34, 52, 235);
borderCol = QColor(90, 165, 235);
badgeBgCol = QColor(30, 55, 85);
badgeTextCol = QColor(160, 215, 255);
statusText = Localization::instance().get("ui.tech_tree.status_available", "AVAILABLE");
} else {
bgCol = QColor(18, 23, 34, 225);
borderCol = QColor(65, 80, 105);
badgeBgCol = QColor(32, 38, 52);
badgeTextCol = QColor(150, 165, 190);
statusText = Localization::instance().get("ui.tech_tree.status_locked", "LOCKED");
}
if (isHovered && !isResearched) {
bgCol = bgCol.lighter(122);
borderCol = borderCol.lighter(125);
}
painter.setBrush(bgCol);
painter.setPen(QPen(borderCol, (isActive || isQueued || isHovered) ? 2.0 : 1.3));
painter.drawRoundedRect(nRect, 7, 7);
// Icon
QImage tIcon = getTintedIcon(cfg.iconPath, isResearched ? QColor(100, 245, 140) : QColor(90, 205, 255));
painter.drawImage(QRect(nRect.left() + 8, nRect.top() + 8, 22, 22), tIcon);
// Tech Name
painter.setFont(QFont("sans-serif", 8, QFont::Bold));
painter.setPen(QColor(240, 248, 255));
painter.drawText(QRect(nRect.left() + 34, nRect.top() + 6, nRect.width() - 112, 16),
Qt::AlignLeft | Qt::AlignVCenter, cfg.name);
// Status Pill (Top-Right)
QRect statusRect(nRect.right() - 74, nRect.top() + 6, 68, 16);
painter.setBrush(badgeBgCol);
painter.setPen(QPen(borderCol, 0.8));
painter.drawRoundedRect(statusRect, 3, 3);
painter.setFont(QFont("sans-serif", 6, QFont::Bold));
painter.setPen(badgeTextCol);
painter.drawText(statusRect, Qt::AlignCenter, statusText);
// Science Cost / Partial Progress Bar
QRect progBarBg(nRect.left() + 34, nRect.top() + 24, nRect.width() - 42, 13);
painter.setBrush(QColor(12, 18, 26));
painter.setPen(QPen(QColor(45, 65, 95), 0.8));
painter.drawRoundedRect(progBarBg, 3, 3);
if (curProg > 0) {
QRect progBarFg(progBarBg.left(), progBarBg.top(), (int)(progBarBg.width() * progFrac), progBarBg.height());
painter.setBrush(isResearched ? QColor(50, 175, 90) : QColor(45, 155, 225));
painter.setPen(Qt::NoPen);
painter.drawRoundedRect(progBarFg, 3, 3);
}
painter.setFont(QFont("sans-serif", 6, QFont::Bold));
painter.setPen(Qt::white);
QString costLabel;
if (isResearched) {
costLabel = QString("%L1 / %L2 (100%)").arg(cfg.scienceCost).arg(cfg.scienceCost);
} else if (curProg > 0) {
costLabel = QString("%L1 / %L2 (%3%) - ~%4t")
.arg(curProg).arg(cfg.scienceCost).arg(pct).arg(estTicks);
} else {
QString tickWord = (estTicks == 1)
? Localization::instance().get("ui.tech_tree.tick_singular", "tick")
: Localization::instance().get("ui.tech_tree.tick_plural", "ticks");
costLabel = Localization::instance().get("ui.tech_tree.cost_format", "%L1 Science (~%2 %3)")
.arg(cfg.scienceCost).arg(estTicks).arg(tickWord);
}
painter.drawText(progBarBg, Qt::AlignCenter, costLabel);
// Unlocks & Obsoletes lines
int infoY = nRect.top() + 42;
QStringList unlocks = GameManager::instance().getTechUnlocks(tId);
QStringList obsoletes = GameManager::instance().getTechObsoletes(tId);
if (!unlocks.isEmpty()) {
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(110, 240, 150));
QString uStr = Localization::instance().get("ui.tech_tree.unlocks_label", "Unlocks: %1")
.arg(unlocks.join(", "));
painter.drawText(QRect(nRect.left() + 8, infoY, nRect.width() - 16, 14),
Qt::AlignLeft | Qt::AlignVCenter, uStr);
infoY += 14;
}
if (!obsoletes.isEmpty()) {
painter.setFont(QFont("sans-serif", 7, QFont::Bold));
painter.setPen(QColor(255, 155, 95));
QString oStr = Localization::instance().get("ui.tech_tree.obsoletes_label", "Obsoletes: %1")
.arg(obsoletes.join(", "));
painter.drawText(QRect(nRect.left() + 8, infoY, nRect.width() - 16, 14),
Qt::AlignLeft | Qt::AlignVCenter, oStr);
infoY += 14;
}
// Description text in remaining bottom space
if (infoY + 12 <= nRect.bottom() - 2) {
painter.setFont(QFont("sans-serif", 6));
painter.setPen(QColor(165, 185, 210));
painter.drawText(QRect(nRect.left() + 8, infoY, nRect.width() - 16, nRect.bottom() - infoY - 3),
Qt::AlignLeft | Qt::AlignTop | Qt::TextWordWrap, cfg.description);
}
}
painter.restore();
// 4. Footer Help Text
QRect footerRect(modalRect.left() + 16, modalRect.bottom() - footerH - 4, modalRect.width() - 32, footerH);
painter.setFont(QFont("sans-serif", 7, QFont::StyleItalic));
painter.setPen(QColor(150, 180, 215));
painter.drawText(footerRect, Qt::AlignCenter,
Localization::instance().get("ui.tech_tree.help_text",
"Left-click a tech to research (prerequisites auto-queued by least remaining work) | Shift+Click to append | Right-click to remove | Drag/Scroll to pan"));
}