#include "qscenerenderer.h" #include #include #include #include #include #include #include #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(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(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 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); } // Recharge bar below military units when recharging if (unit->config.canAttack() && unit->isRecharging()) { float rechargePercent = std::max(0.0f, std::min(1.0f, unit->rechargeTimer / unit->config.rechargeTime)); QRectF recBg(headCenter.x() - 14.0f, headCenter.y() - R - 5.5f, 28.0f, 3.5f); painter.setBrush(QColor(30, 30, 30, 220)); painter.setPen(Qt::NoPen); painter.drawRoundedRect(recBg, 1.5, 1.5); QRectF recFg(headCenter.x() - 14.0f, headCenter.y() - R - 5.5f, 28.0f * (1.0f - rechargePercent), 3.5f); painter.setBrush(QColor(240, 50, 50)); painter.drawRoundedRect(recFg, 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> &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> 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> 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> 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> 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> 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 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 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 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")); }