Browse Source

Feature: add Barracks unit, registry, and HUD production panel; can recruit Archers near selected Barracks; spawn starter Barracks

djeada 2 months ago
parent
commit
07ece1399e

+ 295 - 8
app/game_engine.cpp

@@ -14,14 +14,17 @@
 #include "game/systems/combat_system.h"
 #include "game/systems/combat_system.h"
 #include "game/systems/selection_system.h"
 #include "game/systems/selection_system.h"
 #include "game/systems/arrow_system.h"
 #include "game/systems/arrow_system.h"
+#include "game/systems/production_system.h"
 #include "game/map/map_loader.h"
 #include "game/map/map_loader.h"
 #include "game/map/map_transformer.h"
 #include "game/map/map_transformer.h"
 #include "game/visuals/visual_catalog.h"
 #include "game/visuals/visual_catalog.h"
 #include "game/units/factory.h"
 #include "game/units/factory.h"
+#include "game/units/unit.h"
 #include "game/map/environment.h"
 #include "game/map/environment.h"
 
 
 #include "selected_units_model.h"
 #include "selected_units_model.h"
 #include <cmath>
 #include <cmath>
+#include <limits>
 GameEngine::GameEngine() {
 GameEngine::GameEngine() {
     m_world    = std::make_unique<Engine::Core::World>();
     m_world    = std::make_unique<Engine::Core::World>();
     m_renderer = std::make_unique<Render::GL::Renderer>();
     m_renderer = std::make_unique<Render::GL::Renderer>();
@@ -34,6 +37,7 @@ GameEngine::GameEngine() {
     m_world->addSystem(std::make_unique<Game::Systems::MovementSystem>());
     m_world->addSystem(std::make_unique<Game::Systems::MovementSystem>());
     m_world->addSystem(std::make_unique<Game::Systems::CombatSystem>());
     m_world->addSystem(std::make_unique<Game::Systems::CombatSystem>());
     m_world->addSystem(std::make_unique<Game::Systems::AISystem>());
     m_world->addSystem(std::make_unique<Game::Systems::AISystem>());
+    m_world->addSystem(std::make_unique<Game::Systems::ProductionSystem>());
 
 
     m_selectionSystem = std::make_unique<Game::Systems::SelectionSystem>();
     m_selectionSystem = std::make_unique<Game::Systems::SelectionSystem>();
     m_world->addSystem(std::make_unique<Game::Systems::SelectionSystem>());
     m_world->addSystem(std::make_unique<Game::Systems::SelectionSystem>());
@@ -92,13 +96,125 @@ void GameEngine::onRightClick(qreal sx, qreal sy) {
     }
     }
 }
 }
 
 
+void GameEngine::setHoverAtScreen(qreal sx, qreal sy) {
+    if (!m_window) return;
+    ensureInitialized();
+    // Negative coords are used by QML to signal hover exit
+    if (sx < 0 || sy < 0) {
+        if (m_hoveredBuildingId != 0) { m_hoveredBuildingId = 0; }
+        return;
+    }
+    auto prevHover = m_hoveredBuildingId;
+    m_hoveredBuildingId = 0;
+
+    // Helper: project a base rectangle (XZ) to screen and return bounds
+    auto projectBounds = [&](const QVector3D& center, float hx, float hz, QRectF& out) -> bool {
+        QPointF p1, p2, p3, p4;
+        bool ok1 = worldToScreen(QVector3D(center.x() - hx, center.y() + 0.0f, center.z() - hz), p1);
+        bool ok2 = worldToScreen(QVector3D(center.x() + hx, center.y() + 0.0f, center.z() - hz), p2);
+        bool ok3 = worldToScreen(QVector3D(center.x() + hx, center.y() + 0.0f, center.z() + hz), p3);
+        bool ok4 = worldToScreen(QVector3D(center.x() - hx, center.y() + 0.0f, center.z() + hz), p4);
+        if (!(ok1 && ok2 && ok3 && ok4)) return false;
+        float minX = std::min(std::min(float(p1.x()), float(p2.x())), std::min(float(p3.x()), float(p4.x())));
+        float maxX = std::max(std::max(float(p1.x()), float(p2.x())), std::max(float(p3.x()), float(p4.x())));
+        float minY = std::min(std::min(float(p1.y()), float(p2.y())), std::min(float(p3.y()), float(p4.y())));
+        float maxY = std::max(std::max(float(p1.y()), float(p2.y())), std::max(float(p3.y()), float(p4.y())));
+        out = QRectF(QPointF(minX, minY), QPointF(maxX, maxY));
+        return true;
+    };
+
+    // Hysteresis: keep current hover if mouse stays within an expanded screen-space bounds
+    if (prevHover) {
+        if (auto* e = m_world->getEntity(prevHover)) {
+            if (e->hasComponent<Engine::Core::BuildingComponent>()) {
+                if (auto* t = e->getComponent<Engine::Core::TransformComponent>()) {
+                    // If production UI is active (inProgress), be extra forgiving
+                    float pxPad = 12.0f;
+                    if (auto* prod = e->getComponent<Engine::Core::ProductionComponent>()) {
+                        if (prod->inProgress) pxPad = 24.0f;
+                    }
+                    const float marginXZ_keep = 1.10f;
+                    const float pad_keep  = 1.12f;
+                    float hxk = std::max(0.4f, t->scale.x * marginXZ_keep * pad_keep);
+                    float hzk = std::max(0.4f, t->scale.z * marginXZ_keep * pad_keep);
+                    QRectF bounds;
+                    if (projectBounds(QVector3D(t->position.x, t->position.y, t->position.z), hxk, hzk, bounds)) {
+                        bounds.adjust(-pxPad, -pxPad, pxPad, pxPad);
+                        if (bounds.contains(QPointF(sx, sy))) {
+                            m_hoveredBuildingId = prevHover;
+                            m_hoverGraceTicks = 6;
+                            return;
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    float bestD2 = std::numeric_limits<float>::max();
+    auto ents = m_world->getEntitiesWith<Engine::Core::TransformComponent>();
+    for (auto* e : ents) {
+        if (!e->hasComponent<Engine::Core::UnitComponent>()) continue;
+        if (!e->hasComponent<Engine::Core::BuildingComponent>()) continue;
+        auto* t = e->getComponent<Engine::Core::TransformComponent>();
+        // Screen-space bounds of the building base with modest padding
+        const float marginXZ = 1.10f;
+        const float hoverPad  = 1.06f;
+        float hx = std::max(0.4f, t->scale.x * marginXZ * hoverPad);
+        float hz = std::max(0.4f, t->scale.z * marginXZ * hoverPad);
+        QRectF bounds;
+        if (!projectBounds(QVector3D(t->position.x, t->position.y, t->position.z), hx, hz, bounds)) continue;
+        if (!bounds.contains(QPointF(sx, sy))) continue;
+        // Break ties by closeness to projected center
+        QPointF centerSp;
+        if (!worldToScreen(QVector3D(t->position.x, t->position.y, t->position.z), centerSp)) centerSp = bounds.center();
+        float dx = float(sx) - float(centerSp.x());
+        float dy = float(sy) - float(centerSp.y());
+        float d2 = dx*dx + dy*dy;
+        if (d2 < bestD2) { bestD2 = d2; m_hoveredBuildingId = e->getId(); }
+    }
+
+    // If we acquired (or re-acquired) a hover, extend grace a bit to ride through transient changes
+    if (m_hoveredBuildingId != 0 && m_hoveredBuildingId != prevHover) {
+        m_hoverGraceTicks = 6;
+    }
+
+    // Hysteresis: if we had a previous hover, allow it to persist with a slightly larger screen-space pad
+    if (m_hoveredBuildingId == 0 && prevHover != 0) {
+        if (auto* e = m_world->getEntity(prevHover)) {
+            auto* t = e->getComponent<Engine::Core::TransformComponent>();
+            if (t && e->getComponent<Engine::Core::BuildingComponent>()) {
+                // Grow keep pad slightly more if production is in progress
+                const float marginXZ = 1.12f; // tiny extra pad
+                const float keepPad  = (e->getComponent<Engine::Core::ProductionComponent>() && e->getComponent<Engine::Core::ProductionComponent>()->inProgress) ? 1.16f : 1.12f;
+                float hx = std::max(0.4f, t->scale.x * marginXZ * keepPad);
+                float hz = std::max(0.4f, t->scale.z * marginXZ * keepPad);
+                QRectF bounds;
+                if (projectBounds(QVector3D(t->position.x, t->position.y, t->position.z), hx, hz, bounds)) {
+                    if (bounds.contains(QPointF(sx, sy))) {
+                        m_hoveredBuildingId = prevHover;
+                    }
+                }
+            }
+        }
+    }
+
+    // Grace period: avoid rapid clear/re-acquire near edges
+    if (m_hoveredBuildingId == 0 && prevHover != 0 && m_hoverGraceTicks > 0) {
+        m_hoveredBuildingId = prevHover;
+    }
+}
+
 void GameEngine::onClickSelect(qreal sx, qreal sy, bool additive) {
 void GameEngine::onClickSelect(qreal sx, qreal sy, bool additive) {
     if (!m_window || !m_selectionSystem) return;
     if (!m_window || !m_selectionSystem) return;
     ensureInitialized();
     ensureInitialized();
     // Pick closest unit to the cursor in screen space within a radius
     // Pick closest unit to the cursor in screen space within a radius
-    const float pickRadius = 18.0f; // pixels
-    float bestDist2 = pickRadius * pickRadius;
-    Engine::Core::EntityID bestId = 0;
+    const float baseUnitPickRadius = 18.0f;       // pixels
+    const float baseBuildingPickRadius = 28.0f;   // base px, used as fallback when projection fails
+    float bestUnitDist2 = std::numeric_limits<float>::max();
+    float bestBuildingDist2 = std::numeric_limits<float>::max();
+    Engine::Core::EntityID bestUnitId = 0;
+    Engine::Core::EntityID bestBuildingId = 0;
     auto ents = m_world->getEntitiesWith<Engine::Core::TransformComponent>();
     auto ents = m_world->getEntitiesWith<Engine::Core::TransformComponent>();
     for (auto* e : ents) {
     for (auto* e : ents) {
         if (!e->hasComponent<Engine::Core::UnitComponent>()) continue;
         if (!e->hasComponent<Engine::Core::UnitComponent>()) continue;
@@ -110,13 +226,65 @@ void GameEngine::onClickSelect(qreal sx, qreal sy, bool additive) {
         float dx = float(sx) - float(sp.x());
         float dx = float(sx) - float(sp.x());
         float dy = float(sy) - float(sp.y());
         float dy = float(sy) - float(sp.y());
         float d2 = dx*dx + dy*dy;
         float d2 = dx*dx + dy*dy;
-        if (d2 < bestDist2) { bestDist2 = d2; bestId = e->getId(); }
+        if (e->hasComponent<Engine::Core::BuildingComponent>()) {
+            // Prefer accurate hit test using projected 3D bounding box (covers roof/body)
+            bool hit = false;
+            float pickDist2 = d2; // default to center distance for tie-break
+            // Generous half-extents to cover composed mesh (foundation/roof overhangs)
+            const float marginXZ = 1.6f;
+            const float marginY  = 1.2f;
+            float hx = std::max(0.6f, t->scale.x * marginXZ);
+            float hz = std::max(0.6f, t->scale.z * marginXZ);
+            float hy = std::max(0.5f, t->scale.y * marginY);
+            QVector<QPointF> pts;
+            pts.reserve(8);
+            auto project = [&](const QVector3D& w){ QPointF sp; if (worldToScreen(w, sp)) { pts.push_back(sp); return true; } return false; };
+            bool ok =
+                project(QVector3D(t->position.x - hx, t->position.y + 0.0f, t->position.z - hz)) &&
+                project(QVector3D(t->position.x + hx, t->position.y + 0.0f, t->position.z - hz)) &&
+                project(QVector3D(t->position.x + hx, t->position.y + 0.0f, t->position.z + hz)) &&
+                project(QVector3D(t->position.x - hx, t->position.y + 0.0f, t->position.z + hz)) &&
+                project(QVector3D(t->position.x - hx, t->position.y + hy,   t->position.z - hz)) &&
+                project(QVector3D(t->position.x + hx, t->position.y + hy,   t->position.z - hz)) &&
+                project(QVector3D(t->position.x + hx, t->position.y + hy,   t->position.z + hz)) &&
+                project(QVector3D(t->position.x - hx, t->position.y + hy,   t->position.z + hz));
+            if (ok && pts.size() == 8) {
+                float minX = pts[0].x(), maxX = pts[0].x();
+                float minY = pts[0].y(), maxY = pts[0].y();
+                for (const auto& p2 : pts) { minX = std::min(minX, float(p2.x())); maxX = std::max(maxX, float(p2.x())); minY = std::min(minY, float(p2.y())); maxY = std::max(maxY, float(p2.y())); }
+                if (float(sx) >= minX && float(sx) <= maxX && float(sy) >= minY && float(sy) <= maxY) {
+                    hit = true;
+                    // Use distance to center for tie-break when overlapping multiple buildings
+                    pickDist2 = d2;
+                }
+            }
+            if (!hit) {
+                // Fallback to a scaled circular radius if projection failed
+                float scaleXZ = std::max(std::max(t->scale.x, t->scale.z), 1.0f);
+                float rp = baseBuildingPickRadius * scaleXZ;
+                float r2 = rp * rp;
+                if (d2 <= r2) hit = true;
+            }
+            if (hit && pickDist2 < bestBuildingDist2) { bestBuildingDist2 = pickDist2; bestBuildingId = e->getId(); }
+        } else {
+            float r2 = baseUnitPickRadius * baseUnitPickRadius;
+            if (d2 <= r2 && d2 < bestUnitDist2) { bestUnitDist2 = d2; bestUnitId = e->getId(); }
+        }
     }
     }
-    if (bestId) {
+    // Decide selection target by closest entity under cursor within radius
+    if (bestBuildingId && (!bestUnitId || bestBuildingDist2 <= bestUnitDist2)) {
+        if (!additive) m_selectionSystem->clearSelection();
+        m_selectionSystem->selectUnit(bestBuildingId);
+        syncSelectionFlags();
+        emit selectedUnitsChanged();
+        if (m_selectedUnitsModel) QMetaObject::invokeMethod(m_selectedUnitsModel, "refresh");
+        return;
+    }
+    if (bestUnitId) {
         // If we clicked near a unit, this is a selection click. Optionally clear previous selection.
         // If we clicked near a unit, this is a selection click. Optionally clear previous selection.
         if (!additive) m_selectionSystem->clearSelection();
         if (!additive) m_selectionSystem->clearSelection();
         // Clicked near a unit: (re)select it
         // Clicked near a unit: (re)select it
-        m_selectionSystem->selectUnit(bestId);
+        m_selectionSystem->selectUnit(bestUnitId);
         syncSelectionFlags();
         syncSelectionFlags();
         emit selectedUnitsChanged();
         emit selectedUnitsChanged();
         if (m_selectedUnitsModel) QMetaObject::invokeMethod(m_selectedUnitsModel, "refresh");
         if (m_selectedUnitsModel) QMetaObject::invokeMethod(m_selectedUnitsModel, "refresh");
@@ -173,6 +341,8 @@ void GameEngine::onAreaSelected(qreal x1, qreal y1, qreal x2, qreal y2, bool add
     auto ents = m_world->getEntitiesWith<Engine::Core::TransformComponent>();
     auto ents = m_world->getEntitiesWith<Engine::Core::TransformComponent>();
     for (auto* e : ents) {
     for (auto* e : ents) {
         if (!e->hasComponent<Engine::Core::UnitComponent>()) continue;
         if (!e->hasComponent<Engine::Core::UnitComponent>()) continue;
+        // Exclude buildings from rectangle selection
+        if (e->hasComponent<Engine::Core::BuildingComponent>()) continue;
         auto* u = e->getComponent<Engine::Core::UnitComponent>();
         auto* u = e->getComponent<Engine::Core::UnitComponent>();
         if (!u || u->ownerId != m_localOwnerId) continue; // only area-select friendlies
         if (!u || u->ownerId != m_localOwnerId) continue; // only area-select friendlies
         auto* t = e->getComponent<Engine::Core::TransformComponent>();
         auto* t = e->getComponent<Engine::Core::TransformComponent>();
@@ -225,6 +395,23 @@ void GameEngine::initialize() {
         } else {
         } else {
             setupFallbackTestUnit();
             setupFallbackTestUnit();
         }
         }
+        // Spawn a starting barracks for the local player near origin if none present
+        bool hasBarracks = false;
+        for (auto* e : m_world->getEntitiesWith<Engine::Core::UnitComponent>()) {
+            if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) {
+                if (u->unitType == "barracks" && u->ownerId == m_localOwnerId) { hasBarracks = true; break; }
+            }
+        }
+        if (!hasBarracks) {
+            auto reg2 = Game::Map::MapTransformer::getFactoryRegistry();
+            if (reg2) {
+                Game::Units::SpawnParams sp;
+                sp.position = QVector3D(-4.0f, 0.0f, -3.0f);
+                sp.playerId = m_localOwnerId;
+                sp.unitType = "barracks";
+                reg2->create("barracks", *m_world, sp);
+            }
+        }
     } else {
     } else {
         qWarning() << "Map load failed:" << err << "- using fallback unit";
         qWarning() << "Map load failed:" << err << "- using fallback unit";
         Game::Map::Environment::applyDefault(*m_renderer, *m_camera);
         Game::Map::Environment::applyDefault(*m_renderer, *m_camera);
@@ -244,6 +431,8 @@ void GameEngine::update(float dt) {
         dt *= m_timeScale;
         dt *= m_timeScale;
     }
     }
     if (m_world) m_world->update(dt);
     if (m_world) m_world->update(dt);
+        // Decay hover grace window
+        if (m_hoverGraceTicks > 0) --m_hoverGraceTicks;
     // Prune selection of dead units and keep flags in sync
     // Prune selection of dead units and keep flags in sync
     syncSelectionFlags();
     syncSelectionFlags();
     // Update camera follow behavior after world update so positions are fresh
     // Update camera follow behavior after world update so positions are fresh
@@ -283,6 +472,8 @@ void GameEngine::render(int pixelWidth, int pixelHeight) {
            m_camera->setPerspective(m_camera->getFOV(), aspect, m_camera->getNear(), m_camera->getFar());
            m_camera->setPerspective(m_camera->getFOV(), aspect, m_camera->getNear(), m_camera->getFar());
     }
     }
     m_renderer->beginFrame();
     m_renderer->beginFrame();
+    // Provide hovered id for subtle outline
+    if (m_renderer) m_renderer->setHoveredBuildingId(m_hoveredBuildingId);
     m_renderer->renderWorld(m_world.get());
     m_renderer->renderWorld(m_world.get());
     // Render arrows
     // Render arrows
     if (m_arrowSystem) {
     if (m_arrowSystem) {
@@ -335,8 +526,10 @@ void GameEngine::setupFallbackTestUnit() {
 
 
 bool GameEngine::screenToGround(const QPointF& screenPt, QVector3D& outWorld) {
 bool GameEngine::screenToGround(const QPointF& screenPt, QVector3D& outWorld) {
     if (!m_window || !m_camera) return false;
     if (!m_window || !m_camera) return false;
-    return m_camera->screenToGround(float(screenPt.x()), float(screenPt.y()),
-                                    float(m_window->width()), float(m_window->height()), outWorld);
+    // Prefer the active GL viewport size; fall back to window size if not set yet
+    float w = (m_viewW > 0 ? float(m_viewW) : float(m_window->width()));
+    float h = (m_viewH > 0 ? float(m_viewH) : float(m_window->height()));
+    return m_camera->screenToGround(float(screenPt.x()), float(screenPt.y()), w, h, outWorld);
 }
 }
 
 
 bool GameEngine::worldToScreen(const QVector3D& world, QPointF& outScreen) const {
 bool GameEngine::worldToScreen(const QVector3D& world, QPointF& outScreen) const {
@@ -453,3 +646,97 @@ void GameEngine::cameraSetFollowLerp(float alpha) {
 }
 }
 
 
 QObject* GameEngine::selectedUnitsModel() { return m_selectedUnitsModel; }
 QObject* GameEngine::selectedUnitsModel() { return m_selectedUnitsModel; }
+
+bool GameEngine::hasSelectedType(const QString& type) const {
+    if (!m_selectionSystem || !m_world) return false;
+    const auto& sel = m_selectionSystem->getSelectedUnits();
+    for (auto id : sel) {
+        if (auto* e = m_world->getEntity(id)) {
+            if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) {
+                if (QString::fromStdString(u->unitType) == type) return true;
+            }
+        }
+    }
+    return false;
+}
+
+void GameEngine::recruitNearSelected(const QString& unitType) {
+    ensureInitialized();
+    if (!m_world) return;
+    if (!m_selectionSystem) return;
+    const auto& sel = m_selectionSystem->getSelectedUnits();
+    if (sel.empty()) return;
+    // Find first selected barracks of local player
+    for (auto id : sel) {
+        if (auto* e = m_world->getEntity(id)) {
+            auto* u = e->getComponent<Engine::Core::UnitComponent>();
+            auto* t = e->getComponent<Engine::Core::TransformComponent>();
+            auto* p = e->getComponent<Engine::Core::ProductionComponent>();
+            if (!u || !t) continue;
+            if (u->unitType == "barracks" && u->ownerId == m_localOwnerId) {
+                if (!p) { p = e->addComponent<Engine::Core::ProductionComponent>(); }
+                if (!p) return;
+                if (p->producedCount >= p->maxUnits) return; // cap reached
+                if (p->inProgress) return; // already building
+                // Start a new production order
+                p->productType = unitType.toStdString();
+                // Build time could vary by unit type in future; keep current
+                p->timeRemaining = p->buildTime;
+                p->inProgress = true;
+                return;
+            }
+        }
+    }
+}
+
+QVariantMap GameEngine::getSelectedProductionState() const {
+    QVariantMap m;
+    m["hasBarracks"] = false;
+    m["inProgress"] = false;
+    m["timeRemaining"] = 0.0;
+    m["buildTime"] = 0.0;
+    m["producedCount"] = 0;
+    m["maxUnits"] = 0;
+    if (!m_selectionSystem || !m_world) return m;
+    const auto& sel = m_selectionSystem->getSelectedUnits();
+    for (auto id : sel) {
+        if (auto* e = m_world->getEntity(id)) {
+            if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) {
+                if (u->unitType == "barracks") {
+                    m["hasBarracks"] = true;
+                    if (auto* p = e->getComponent<Engine::Core::ProductionComponent>()) {
+                        m["inProgress"] = p->inProgress;
+                        m["timeRemaining"] = p->timeRemaining;
+                        m["buildTime"] = p->buildTime;
+                        m["producedCount"] = p->producedCount;
+                        m["maxUnits"] = p->maxUnits;
+                    }
+                    break;
+                }
+            }
+        }
+    }
+    return m;
+}
+
+void GameEngine::setRallyAtScreen(qreal sx, qreal sy) {
+    ensureInitialized();
+    if (!m_world || !m_selectionSystem) return;
+    QVector3D hit;
+    if (!screenToGround(QPointF(sx, sy), hit)) return;
+    const auto& sel = m_selectionSystem->getSelectedUnits();
+    for (auto id : sel) {
+        if (auto* e = m_world->getEntity(id)) {
+            if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) {
+                if (u->unitType == "barracks") {
+                    if (auto* p = e->getComponent<Engine::Core::ProductionComponent>()) {
+                        p->rallyX = hit.x();
+                        p->rallyZ = hit.z();
+                        p->rallySet = true;
+                        return;
+                    }
+                }
+            }
+        }
+    }
+}

+ 11 - 0
app/game_engine.h

@@ -6,6 +6,7 @@
 #include <QPointF>
 #include <QPointF>
 #include <memory>
 #include <memory>
 #include <algorithm>
 #include <algorithm>
+#include <QVariant>
 
 
 namespace Engine { namespace Core {
 namespace Engine { namespace Core {
 class World;
 class World;
@@ -39,6 +40,7 @@ public:
     Q_INVOKABLE void onRightClick(qreal sx, qreal sy);
     Q_INVOKABLE void onRightClick(qreal sx, qreal sy);
     Q_INVOKABLE void onClickSelect(qreal sx, qreal sy, bool additive = false);
     Q_INVOKABLE void onClickSelect(qreal sx, qreal sy, bool additive = false);
     Q_INVOKABLE void onAreaSelected(qreal x1, qreal y1, qreal x2, qreal y2, bool additive = false);
     Q_INVOKABLE void onAreaSelected(qreal x1, qreal y1, qreal x2, qreal y2, bool additive = false);
+    Q_INVOKABLE void setHoverAtScreen(qreal sx, qreal sy);
 
 
     // Camera controls exposed to QML
     // Camera controls exposed to QML
     Q_INVOKABLE void cameraMove(float dx, float dz);      // move along ground plane (right/forward XZ)
     Q_INVOKABLE void cameraMove(float dx, float dz);      // move along ground plane (right/forward XZ)
@@ -52,6 +54,12 @@ public:
     Q_INVOKABLE void setPaused(bool paused) { m_paused = paused; }
     Q_INVOKABLE void setPaused(bool paused) { m_paused = paused; }
     Q_INVOKABLE void setGameSpeed(float speed) { m_timeScale = std::max(0.0f, speed); }
     Q_INVOKABLE void setGameSpeed(float speed) { m_timeScale = std::max(0.0f, speed); }
 
 
+    // Selection queries and actions (for HUD/production)
+    Q_INVOKABLE bool hasSelectedType(const QString& type) const;
+    Q_INVOKABLE void recruitNearSelected(const QString& unitType);
+    Q_INVOKABLE QVariantMap getSelectedProductionState() const; // {hasBarracks, inProgress, timeRemaining, buildTime, producedCount, maxUnits}
+    Q_INVOKABLE void setRallyAtScreen(qreal sx, qreal sy);
+
     void setWindow(QQuickWindow* w) { m_window = w; }
     void setWindow(QQuickWindow* w) { m_window = w; }
 
 
     // Render-thread friendly calls (must be invoked when a valid GL context is current)
     // Render-thread friendly calls (must be invoked when a valid GL context is current)
@@ -96,6 +104,9 @@ private:
     float m_camFar = 1000.0f;
     float m_camFar = 1000.0f;
     QObject* m_selectedUnitsModel = nullptr;
     QObject* m_selectedUnitsModel = nullptr;
     int m_localOwnerId = 1; // local player's owner/team id
     int m_localOwnerId = 1; // local player's owner/team id
+    // Hover state for subtle outline
+    Engine::Core::EntityID m_hoveredBuildingId = 0;
+    int m_hoverGraceTicks = 0; // small grace to avoid flicker when near edges
 signals:
 signals:
     void selectedUnitsChanged();
     void selectedUnitsChanged();
 
 

+ 2 - 0
app/selected_units_model.cpp

@@ -81,6 +81,8 @@ void SelectedUnitsModel::refresh() {
     } else {
     } else {
         for (auto id : ids) {
         for (auto id : ids) {
             if (auto* e = world->getEntity(id)) {
             if (auto* e = world->getEntity(id)) {
+                // Exclude buildings from list view
+                if (e->hasComponent<Engine::Core::BuildingComponent>()) continue;
                 if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) {
                 if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) {
                     if (u->health > 0) m_ids.push_back(id);
                     if (u->health > 0) m_ids.push_back(id);
                 }
                 }

+ 2 - 1
assets/shaders/basic.frag

@@ -7,6 +7,7 @@ in vec3 v_worldPos;
 uniform sampler2D u_texture;
 uniform sampler2D u_texture;
 uniform vec3 u_color;
 uniform vec3 u_color;
 uniform bool u_useTexture;
 uniform bool u_useTexture;
+uniform float u_alpha;
 
 
 out vec4 FragColor;
 out vec4 FragColor;
 
 
@@ -19,5 +20,5 @@ void main() {
     vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
     vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
     float diff = max(dot(normal, lightDir), 0.2);
     float diff = max(dot(normal, lightDir), 0.2);
     color *= diff;
     color *= diff;
-    FragColor = vec4(color, 1.0);
+    FragColor = vec4(color, u_alpha);
 }
 }

+ 2 - 0
game/CMakeLists.txt

@@ -18,6 +18,7 @@ add_library(game_systems STATIC
     systems/pathfinding.cpp
     systems/pathfinding.cpp
     systems/selection_system.cpp
     systems/selection_system.cpp
     systems/arrow_system.cpp
     systems/arrow_system.cpp
+    systems/production_system.cpp
     map/map_loader.cpp
     map/map_loader.cpp
     map/map_transformer.cpp
     map/map_transformer.cpp
     map/environment.cpp
     map/environment.cpp
@@ -25,6 +26,7 @@ add_library(game_systems STATIC
     units/unit.cpp
     units/unit.cpp
     units/archer.cpp
     units/archer.cpp
     units/factory.cpp
     units/factory.cpp
+    units/barracks.cpp
 )
 )
 
 
 target_include_directories(game_systems PUBLIC .)
 target_include_directories(game_systems PUBLIC .)

+ 26 - 0
game/core/component.h

@@ -72,3 +72,29 @@ public:
 };
 };
 
 
 } // namespace Engine::Core
 } // namespace Engine::Core
+
+namespace Engine::Core {
+
+class BuildingComponent : public Component {
+public:
+    BuildingComponent() = default; // marker component for buildings (non-squad units)
+};
+
+class ProductionComponent : public Component {
+public:
+    ProductionComponent()
+                : inProgress(false), buildTime(4.0f), timeRemaining(0.0f),
+                    producedCount(0), maxUnits(5), productType("archer"),
+                    rallyX(0.0f), rallyZ(0.0f), rallySet(false) {}
+
+    bool inProgress;
+    float buildTime;      // seconds for one unit
+    float timeRemaining;  // seconds left for current production
+    int producedCount;    // how many produced by this building
+    int maxUnits;         // cap after which building can't produce more
+    std::string productType;
+    float rallyX, rallyZ; // rally point on ground plane (XZ)
+    bool rallySet;        // true if rally point explicitly set
+};
+
+} // namespace Engine::Core

+ 13 - 11
game/systems/movement_system.cpp

@@ -71,17 +71,19 @@ void MovementSystem::moveUnit(Engine::Core::Entity* entity, float deltaTime) {
     transform->position.x += movement->vx * deltaTime;
     transform->position.x += movement->vx * deltaTime;
     transform->position.z += movement->vz * deltaTime;
     transform->position.z += movement->vz * deltaTime;
 
 
-    // Face movement direction smoothly
-    float speed2 = movement->vx*movement->vx + movement->vz*movement->vz;
-    if (speed2 > 1e-5f) {
-        float targetYaw = std::atan2(movement->vx, movement->vz) * 180.0f / 3.14159265f; // yaw around Y
-        // Smoothly interpolate rotation.y toward targetYaw
-        float current = transform->rotation.y;
-        // shortest angle difference
-        float diff = std::fmod((targetYaw - current + 540.0f), 360.0f) - 180.0f;
-        float turnSpeed = 720.0f; // deg/sec
-        float step = std::clamp(diff, -turnSpeed * deltaTime, turnSpeed * deltaTime);
-        transform->rotation.y = current + step;
+    // Face movement direction smoothly (units only; buildings remain stationary orientation)
+    if (!entity->hasComponent<Engine::Core::BuildingComponent>()) {
+        float speed2 = movement->vx*movement->vx + movement->vz*movement->vz;
+        if (speed2 > 1e-5f) {
+            float targetYaw = std::atan2(movement->vx, movement->vz) * 180.0f / 3.14159265f; // yaw around Y
+            // Smoothly interpolate rotation.y toward targetYaw
+            float current = transform->rotation.y;
+            // shortest angle difference
+            float diff = std::fmod((targetYaw - current + 540.0f), 360.0f) - 180.0f;
+            float turnSpeed = 720.0f; // deg/sec
+            float step = std::clamp(diff, -turnSpeed * deltaTime, turnSpeed * deltaTime);
+            transform->rotation.y = current + step;
+        }
     }
     }
 }
 }
 
 

+ 51 - 0
game/systems/production_system.cpp

@@ -0,0 +1,51 @@
+#include "production_system.h"
+#include "../core/world.h"
+#include "../core/component.h"
+#include "../map/map_transformer.h"
+#include "../units/factory.h"
+
+namespace Game { namespace Systems {
+
+void ProductionSystem::update(Engine::Core::World* world, float deltaTime) {
+    if (!world) return;
+    auto entities = world->getEntitiesWith<Engine::Core::ProductionComponent>();
+    for (auto* e : entities) {
+        auto* prod = e->getComponent<Engine::Core::ProductionComponent>();
+        if (!prod) continue;
+        if (!prod->inProgress) continue;
+        if (prod->producedCount >= prod->maxUnits) { prod->inProgress = false; continue; }
+        prod->timeRemaining -= deltaTime;
+        if (prod->timeRemaining <= 0.0f) {
+            // Spawn the unit near the building; use Transform to position
+            auto* t = e->getComponent<Engine::Core::TransformComponent>();
+            auto* u = e->getComponent<Engine::Core::UnitComponent>();
+            if (t && u) {
+                // Prefer rally point if set; otherwise place in a ring outside the building
+                QVector3D spawnPos;
+                if (prod->rallySet) {
+                    spawnPos = QVector3D(prod->rallyX, 0.0f, prod->rallyZ);
+                } else {
+                    float radius = 3.0f + 0.3f * float(prod->producedCount % 10);
+                    float angle = 0.6f * float(prod->producedCount);
+                    spawnPos = QVector3D(t->position.x + radius * std::cos(angle), 0.0f,
+                                         t->position.z + radius * std::sin(angle));
+                }
+                auto reg = Game::Map::MapTransformer::getFactoryRegistry();
+                if (reg) {
+                    Game::Units::SpawnParams sp;
+                    sp.position = spawnPos;
+                    sp.playerId = u->ownerId;
+                    sp.unitType = prod->productType;
+                    reg->create(prod->productType, *world, sp);
+                }
+                // Update production state
+                prod->producedCount += 1;
+            }
+            // If max reached, stop. Otherwise ready for next order.
+            prod->inProgress = false;
+            prod->timeRemaining = 0.0f;
+        }
+    }
+}
+
+} } // namespace Game::Systems

+ 12 - 0
game/systems/production_system.h

@@ -0,0 +1,12 @@
+#pragma once
+
+#include "../core/system.h"
+
+namespace Game { namespace Systems {
+
+class ProductionSystem : public Engine::Core::System {
+public:
+    void update(Engine::Core::World* world, float deltaTime) override;
+};
+
+} } // namespace Game::Systems

+ 56 - 0
game/units/barracks.cpp

@@ -0,0 +1,56 @@
+#include "barracks.h"
+#include "../core/world.h"
+#include "../core/component.h"
+#include "../visuals/team_colors.h"
+
+namespace Game { namespace Units {
+
+Barracks::Barracks(Engine::Core::World& world)
+    : Unit(world, "barracks") {}
+
+std::unique_ptr<Barracks> Barracks::Create(Engine::Core::World& world, const SpawnParams& params) {
+    auto unit = std::unique_ptr<Barracks>(new Barracks(world));
+    unit->init(params);
+    return unit;
+}
+
+void Barracks::init(const SpawnParams& params) {
+    auto* e = m_world->createEntity();
+    m_id = e->getId();
+
+    // Transform: bigger static building
+    m_t = e->addComponent<Engine::Core::TransformComponent>();
+    m_t->position = {params.position.x(), params.position.y(), params.position.z()};
+    m_t->scale = {1.8f, 1.2f, 1.8f};
+
+    // Renderable: use generic cube mesh and team color
+    m_r = e->addComponent<Engine::Core::RenderableComponent>("", "");
+    m_r->visible = true;
+    m_r->mesh = Engine::Core::RenderableComponent::MeshKind::Cube;
+
+    m_u = e->addComponent<Engine::Core::UnitComponent>();
+    m_u->unitType = m_type;
+    m_u->health = 600; m_u->maxHealth = 600; m_u->speed = 0.0f; // static
+    m_u->ownerId = params.playerId;
+
+    QVector3D tc = Game::Visuals::teamColorForOwner(m_u->ownerId);
+    m_r->color[0] = tc.x(); m_r->color[1] = tc.y(); m_r->color[2] = tc.z();
+
+    // Tag as building (affects selection/display)
+    e->addComponent<Engine::Core::BuildingComponent>();
+
+    // Production capability: produces archers, capped by maxUnits
+    if (auto* prod = e->addComponent<Engine::Core::ProductionComponent>()) {
+        prod->productType = "archer";
+        prod->buildTime = 10.0f;  // seconds per archer
+        prod->maxUnits = 100;     // cap total archers produced by this barracks
+        prod->inProgress = false; // wait for order
+        prod->timeRemaining = 0.0f;
+        prod->producedCount = 0;
+        prod->rallyX = m_t->position.x + 4.0f; // basic rally default
+        prod->rallyZ = m_t->position.z + 2.0f;
+        prod->rallySet = true; // default rally is set near building
+    }
+}
+
+} } // namespace Game::Units

+ 16 - 0
game/units/barracks.h

@@ -0,0 +1,16 @@
+#pragma once
+
+#include "unit.h"
+
+namespace Game { namespace Units {
+
+class Barracks : public Unit {
+public:
+    static std::unique_ptr<Barracks> Create(Engine::Core::World& world, const SpawnParams& params);
+
+private:
+    Barracks(Engine::Core::World& world);
+    void init(const SpawnParams& params);
+};
+
+} } // namespace Game::Units

+ 4 - 0
game/units/factory.cpp

@@ -1,5 +1,6 @@
 #include "factory.h"
 #include "factory.h"
 #include "archer.h"
 #include "archer.h"
+#include "barracks.h"
 
 
 namespace Game { namespace Units {
 namespace Game { namespace Units {
 
 
@@ -7,6 +8,9 @@ void registerBuiltInUnits(UnitFactoryRegistry& reg) {
     reg.registerFactory("archer", [](Engine::Core::World& world, const SpawnParams& params){
     reg.registerFactory("archer", [](Engine::Core::World& world, const SpawnParams& params){
         return Archer::Create(world, params);
         return Archer::Create(world, params);
     });
     });
+    reg.registerFactory("barracks", [](Engine::Core::World& world, const SpawnParams& params){
+        return Barracks::Create(world, params);
+    });
 }
 }
 
 
 } } // namespace Game::Units
 } } // namespace Game::Units

+ 1 - 0
render/CMakeLists.txt

@@ -8,6 +8,7 @@ add_library(render_gl STATIC
     gl/resources.cpp
     gl/resources.cpp
     entity/registry.cpp
     entity/registry.cpp
     entity/archer_renderer.cpp
     entity/archer_renderer.cpp
+    entity/barracks_renderer.cpp
     geom/selection_ring.cpp
     geom/selection_ring.cpp
     geom/arrow.cpp
     geom/arrow.cpp
 )
 )

+ 140 - 0
render/entity/barracks_renderer.cpp

@@ -0,0 +1,140 @@
+#include "barracks_renderer.h"
+#include "registry.h"
+#include "../gl/renderer.h"
+#include "../gl/resources.h"
+#include "../../game/core/component.h"
+
+namespace Render::GL {
+
+static void drawBarracks(const DrawParams& p) {
+    if (!p.renderer || !p.resources || !p.entity) return;
+    auto* t = p.entity->getComponent<Engine::Core::TransformComponent>();
+    auto* r = p.entity->getComponent<Engine::Core::RenderableComponent>();
+    auto* u = p.entity->getComponent<Engine::Core::UnitComponent>();
+    if (!t || !r) return;
+
+    // Palette
+    const QVector3D stone(0.55f, 0.55f, 0.55f);
+    const QVector3D wood(0.50f, 0.33f, 0.18f);
+    const QVector3D woodDark(0.35f, 0.23f, 0.12f);
+    const QVector3D thatch(0.85f, 0.72f, 0.25f);
+    const QVector3D doorColor(0.25f, 0.18f, 0.10f);
+    const QVector3D crate(0.45f, 0.30f, 0.14f);
+    const QVector3D path(0.60f, 0.58f, 0.50f);
+    const QVector3D team = QVector3D(r->color[0], r->color[1], r->color[2]);
+
+    // Stone foundation slab (slightly larger footprint)
+    QMatrix4x4 foundation = p.model;
+    foundation.translate(0.0f, -0.15f, 0.0f);
+    foundation.scale(1.35f, 0.10f, 1.2f);
+    p.renderer->drawMeshColored(p.resources->unit(), foundation, stone, p.resources->white());
+
+    // Wooden walls (main volume)
+    QMatrix4x4 walls = p.model;
+    walls.scale(1.15f, 0.65f, 0.95f);
+    p.renderer->drawMeshColored(p.resources->unit(), walls, wood, p.resources->white());
+
+    // Timber beams: vertical corners
+    auto drawBeam = [&](float x, float z) {
+        QMatrix4x4 b = p.model;
+        b.translate(x, 0.20f, z);
+        b.scale(0.06f, 0.75f, 0.06f);
+        p.renderer->drawMeshColored(p.resources->unit(), b, woodDark, p.resources->white());
+    };
+    drawBeam( 0.9f,  0.7f);
+    drawBeam(-0.9f,  0.7f);
+    drawBeam( 0.9f, -0.7f);
+    drawBeam(-0.9f, -0.7f);
+
+    // Pitched thatch roof: layered to suggest a gable
+    QMatrix4x4 roofBase = p.model;
+    roofBase.translate(0.0f, 0.55f, 0.0f);
+    roofBase.scale(1.25f, 0.18f, 1.05f);
+    p.renderer->drawMeshColored(p.resources->unit(), roofBase, thatch, p.resources->white());
+
+    QMatrix4x4 roofMid = p.model;
+    roofMid.translate(0.0f, 0.72f, 0.0f);
+    roofMid.scale(1.05f, 0.14f, 0.95f);
+    p.renderer->drawMeshColored(p.resources->unit(), roofMid, thatch, p.resources->white());
+
+    QMatrix4x4 roofRidge = p.model;
+    roofRidge.translate(0.0f, 0.86f, 0.0f);
+    roofRidge.scale(0.85f, 0.12f, 0.85f);
+    p.renderer->drawMeshColored(p.resources->unit(), roofRidge, thatch, p.resources->white());
+
+    // Roof ridge beam
+    QMatrix4x4 ridge = p.model;
+    ridge.translate(0.0f, 0.96f, 0.0f);
+    ridge.scale(1.1f, 0.04f, 0.12f);
+    p.renderer->drawMeshColored(p.resources->unit(), ridge, woodDark, p.resources->white());
+
+    // Door at front
+    QMatrix4x4 door = p.model;
+    door.translate(0.0f, -0.02f, 0.62f);
+    door.scale(0.25f, 0.38f, 0.06f);
+    p.renderer->drawMeshColored(p.resources->unit(), door, doorColor, p.resources->white());
+
+    // Side annex (shed) on the right
+    QMatrix4x4 annex = p.model;
+    annex.translate(0.95f, -0.05f, -0.15f);
+    annex.scale(0.55f, 0.45f, 0.55f);
+    p.renderer->drawMeshColored(p.resources->unit(), annex, wood, p.resources->white());
+
+    QMatrix4x4 annexRoof = p.model;
+    annexRoof.translate(0.95f, 0.30f, -0.15f);
+    annexRoof.scale(0.60f, 0.12f, 0.60f);
+    p.renderer->drawMeshColored(p.resources->unit(), annexRoof, thatch, p.resources->white());
+
+    // Chimney on the back-left
+    QMatrix4x4 chimney = p.model;
+    chimney.translate(-0.65f, 0.75f, -0.55f);
+    chimney.scale(0.10f, 0.35f, 0.10f);
+    p.renderer->drawMeshColored(p.resources->unit(), chimney, stone, p.resources->white());
+
+    QMatrix4x4 chimneyCap = p.model;
+    chimneyCap.translate(-0.65f, 0.95f, -0.55f);
+    chimneyCap.scale(0.16f, 0.05f, 0.16f);
+    p.renderer->drawMeshColored(p.resources->unit(), chimneyCap, stone, p.resources->white());
+
+    // Path stones leading from door
+    auto drawPaver = [&](float ox, float oz, float sx, float sz) {
+        QMatrix4x4 paver = p.model;
+        paver.translate(ox, -0.14f, oz);
+        paver.scale(sx, 0.02f, sz);
+        p.renderer->drawMeshColored(p.resources->unit(), paver, path, p.resources->white());
+    };
+    drawPaver( 0.0f,  0.9f, 0.25f, 0.20f);
+    drawPaver( 0.0f,  1.15f, 0.22f, 0.18f);
+    drawPaver( 0.0f,  1.35f, 0.20f, 0.16f);
+
+    // Crates near the door
+    QMatrix4x4 crate1 = p.model; crate1.translate(0.45f, -0.05f, 0.55f); crate1.scale(0.18f, 0.18f, 0.18f);
+    p.renderer->drawMeshColored(p.resources->unit(), crate1, crate, p.resources->white());
+    QMatrix4x4 crate2 = p.model; crate2.translate(0.58f, 0.02f, 0.45f); crate2.scale(0.14f, 0.14f, 0.14f);
+    p.renderer->drawMeshColored(p.resources->unit(), crate2, crate, p.resources->white());
+
+    // Simple fence posts on front-left
+    for (int i=0;i<3;++i) {
+        QMatrix4x4 post = p.model;
+        post.translate(-0.85f + 0.18f * i, -0.05f, 0.85f);
+        post.scale(0.05f, 0.25f, 0.05f);
+        p.renderer->drawMeshColored(p.resources->unit(), post, woodDark, p.resources->white());
+    }
+
+    // Banner pole with team-colored flag
+    QMatrix4x4 pole = p.model;
+    pole.translate(-0.9f, 0.55f, -0.55f);
+    pole.scale(0.05f, 0.7f, 0.05f);
+    p.renderer->drawMeshColored(p.resources->unit(), pole, woodDark, p.resources->white());
+
+    QMatrix4x4 banner = p.model;
+    banner.translate(-0.82f, 0.80f, -0.50f);
+    banner.scale(0.35f, 0.22f, 0.02f);
+    p.renderer->drawMeshColored(p.resources->unit(), banner, team, p.resources->white());
+}
+
+void registerBarracksRenderer(EntityRendererRegistry& registry) {
+    registry.registerRenderer("barracks", drawBarracks);
+}
+
+} // namespace Render::GL

+ 9 - 0
render/entity/barracks_renderer.h

@@ -0,0 +1,9 @@
+#pragma once
+
+namespace Render { namespace GL { class EntityRendererRegistry; } }
+
+namespace Render::GL {
+
+void registerBarracksRenderer(EntityRendererRegistry& registry);
+
+} // namespace Render::GL

+ 2 - 0
render/entity/registry.cpp

@@ -1,5 +1,6 @@
 #include "registry.h"
 #include "registry.h"
 #include "archer_renderer.h"
 #include "archer_renderer.h"
+#include "barracks_renderer.h"
 #include "../gl/renderer.h"
 #include "../gl/renderer.h"
 #include "../../game/core/entity.h"
 #include "../../game/core/entity.h"
 #include "../../game/core/component.h"
 #include "../../game/core/component.h"
@@ -18,6 +19,7 @@ RenderFunc EntityRendererRegistry::get(const std::string& type) const {
 
 
 void registerBuiltInEntityRenderers(EntityRendererRegistry& registry) {
 void registerBuiltInEntityRenderers(EntityRendererRegistry& registry) {
     registerArcherRenderer(registry);
     registerArcherRenderer(registry);
+    registerBarracksRenderer(registry);
 }
 }
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 1 - 1
render/geom/selection_ring.cpp

@@ -10,7 +10,7 @@ static Render::GL::Mesh* createRingMesh() {
     std::vector<Vertex> verts;
     std::vector<Vertex> verts;
     std::vector<unsigned int> idx;
     std::vector<unsigned int> idx;
     const int seg = 48;
     const int seg = 48;
-    const float inner = 0.8f;
+    const float inner = 0.94f; // thinner ring band
     const float outer = 1.0f;
     const float outer = 1.0f;
     for (int i = 0; i < seg; ++i) {
     for (int i = 0; i < seg; ++i) {
         float a0 = (i / float(seg)) * 6.2831853f;
         float a0 = (i / float(seg)) * 6.2831853f;

+ 92 - 6
render/gl/renderer.cpp

@@ -6,6 +6,7 @@
 #include <algorithm>
 #include <algorithm>
 #include <cmath>
 #include <cmath>
 #include "../entity/registry.h"
 #include "../entity/registry.h"
+#include "../geom/selection_ring.h"
 
 
 namespace Render::GL {
 namespace Render::GL {
 
 
@@ -116,6 +117,7 @@ void Renderer::drawMesh(Mesh* mesh, const QMatrix4x4& modelMatrix, Texture* text
     m_basicShader->setUniform("u_model", modelMatrix);
     m_basicShader->setUniform("u_model", modelMatrix);
     m_basicShader->setUniform("u_view", m_camera->getViewMatrix());
     m_basicShader->setUniform("u_view", m_camera->getViewMatrix());
     m_basicShader->setUniform("u_projection", m_camera->getProjectionMatrix());
     m_basicShader->setUniform("u_projection", m_camera->getProjectionMatrix());
+    m_basicShader->setUniform("u_alpha", 1.0f);
     
     
     // Bind texture
     // Bind texture
     if (texture) {
     if (texture) {
@@ -145,6 +147,8 @@ void Renderer::drawMeshColored(Mesh* mesh, const QMatrix4x4& modelMatrix, const
     m_basicShader->setUniform("u_model", modelMatrix);
     m_basicShader->setUniform("u_model", modelMatrix);
     m_basicShader->setUniform("u_view", m_camera->getViewMatrix());
     m_basicShader->setUniform("u_view", m_camera->getViewMatrix());
     m_basicShader->setUniform("u_projection", m_camera->getProjectionMatrix());
     m_basicShader->setUniform("u_projection", m_camera->getProjectionMatrix());
+    // Alpha default 1, caller can adjust via another shader call if needed
+    m_basicShader->setUniform("u_alpha", 1.0f);
     if (texture) {
     if (texture) {
         texture->bind(0);
         texture->bind(0);
         m_basicShader->setUniform("u_texture", 0);
         m_basicShader->setUniform("u_texture", 0);
@@ -190,18 +194,70 @@ void Renderer::renderWorld(Engine::Core::World* world) {
     }
     }
     // Draw ground plane with grid using helper
     // Draw ground plane with grid using helper
     renderGridGround();
     renderGridGround();
-    
+
+    // Draw hover ring before entities so buildings naturally occlude it
+    if (m_hoveredBuildingId) {
+        if (auto* hovered = world->getEntity(m_hoveredBuildingId)) {
+            if (hovered->hasComponent<Engine::Core::BuildingComponent>()) {
+                if (auto* t = hovered->getComponent<Engine::Core::TransformComponent>()) {
+                    Mesh* ring = Render::Geom::SelectionRing::get();
+                    if (ring && m_basicShader && m_camera) {
+                        const float marginXZ = 1.25f;
+                        const float pad = 1.06f;
+                        float sx = std::max(0.6f, t->scale.x * marginXZ * pad * 1.5f);
+                        float sz = std::max(0.6f, t->scale.z * marginXZ * pad * 1.5f);
+                        QMatrix4x4 model;
+                        model.translate(t->position.x, 0.01f, t->position.z);
+                        model.scale(sx, 1.0f, sz);
+                        // Shadow-like color (dark gray)
+                        QVector3D c(0.0f, 0.0f, 0.0f);
+                        GLboolean depthEnabled = glIsEnabled(GL_DEPTH_TEST);
+                        if (!depthEnabled) glEnable(GL_DEPTH_TEST);
+                        // Slightly bias depth to the ground and disable depth writes so later geometry can overwrite
+                        glEnable(GL_POLYGON_OFFSET_FILL);
+                        glPolygonOffset(1.0f, 1.0f);
+                        // Disable depth writes so later geometry can overwrite the ring
+                        glDepthMask(GL_FALSE);
+                        m_basicShader->use();
+                        m_basicShader->setUniform("u_model", model);
+                        m_basicShader->setUniform("u_view", m_camera->getViewMatrix());
+                        m_basicShader->setUniform("u_projection", m_camera->getProjectionMatrix());
+                        if (m_resources && m_resources->white()) {
+                            m_resources->white()->bind(0);
+                            m_basicShader->setUniform("u_texture", 0);
+                        }
+                        m_basicShader->setUniform("u_useTexture", false);
+                        m_basicShader->setUniform("u_color", c);
+                        // Soft shadow edge
+                        m_basicShader->setUniform("u_alpha", 0.10f);
+                        QMatrix4x4 feather = model; feather.scale(1.08f, 1.0f, 1.08f);
+                        m_basicShader->setUniform("u_model", feather);
+                        ring->draw();
+                        // Main shadow ring
+                        m_basicShader->setUniform("u_model", model);
+                        m_basicShader->setUniform("u_alpha", 0.28f);
+                        ring->draw();
+                        m_basicShader->release();
+                        glDepthMask(GL_TRUE);
+                        glDisable(GL_POLYGON_OFFSET_FILL);
+                        if (!depthEnabled) glDisable(GL_DEPTH_TEST);
+                    }
+                }
+            }
+        }
+    }
+
     // Get all entities with both transform and renderable components
     // Get all entities with both transform and renderable components
     auto renderableEntities = world->getEntitiesWith<Engine::Core::RenderableComponent>();
     auto renderableEntities = world->getEntitiesWith<Engine::Core::RenderableComponent>();
-    
+
     for (auto entity : renderableEntities) {
     for (auto entity : renderableEntities) {
         auto renderable = entity->getComponent<Engine::Core::RenderableComponent>();
         auto renderable = entity->getComponent<Engine::Core::RenderableComponent>();
         auto transform = entity->getComponent<Engine::Core::TransformComponent>();
         auto transform = entity->getComponent<Engine::Core::TransformComponent>();
-        
+
         if (!renderable->visible || !transform) {
         if (!renderable->visible || !transform) {
             continue;
             continue;
         }
         }
-        
+
         // Build model matrix from transform
         // Build model matrix from transform
         QMatrix4x4 modelMatrix;
         QMatrix4x4 modelMatrix;
         modelMatrix.translate(transform->position.x, transform->position.y, transform->position.z);
         modelMatrix.translate(transform->position.x, transform->position.y, transform->position.z);
@@ -209,7 +265,7 @@ void Renderer::renderWorld(Engine::Core::World* world) {
         modelMatrix.rotate(transform->rotation.y, QVector3D(0, 1, 0));
         modelMatrix.rotate(transform->rotation.y, QVector3D(0, 1, 0));
         modelMatrix.rotate(transform->rotation.z, QVector3D(0, 0, 1));
         modelMatrix.rotate(transform->rotation.z, QVector3D(0, 0, 1));
         modelMatrix.scale(transform->scale.x, transform->scale.y, transform->scale.z);
         modelMatrix.scale(transform->scale.x, transform->scale.y, transform->scale.z);
-        
+
         // If entity has a unitType, try registry-based renderer first
         // If entity has a unitType, try registry-based renderer first
         bool drawnByRegistry = false;
         bool drawnByRegistry = false;
         if (auto* unit = entity->getComponent<Engine::Core::UnitComponent>()) {
         if (auto* unit = entity->getComponent<Engine::Core::UnitComponent>()) {
@@ -222,7 +278,23 @@ void Renderer::renderWorld(Engine::Core::World* world) {
                 }
                 }
             }
             }
         }
         }
-        if (drawnByRegistry) continue;
+        if (drawnByRegistry) {
+            // Draw rally flag marker if this is a barracks with a set rally
+            if (auto* unit = entity->getComponent<Engine::Core::UnitComponent>()) {
+                if (unit->unitType == "barracks") {
+                    if (auto* prod = entity->getComponent<Engine::Core::ProductionComponent>()) {
+                        if (prod->rallySet && m_resources) {
+                            QMatrix4x4 flagModel;
+                            flagModel.translate(prod->rallyX, 0.1f, prod->rallyZ);
+                            flagModel.scale(0.2f, 0.2f, 0.2f);
+                            drawMeshColored(m_resources->unit(), flagModel, QVector3D(1.0f, 0.9f, 0.2f), m_resources->white());
+                        }
+                    }
+                }
+            }
+            continue;
+        }
+
         // Else choose mesh based on RenderableComponent hint
         // Else choose mesh based on RenderableComponent hint
         RenderCommand command;
         RenderCommand command;
         command.modelMatrix = modelMatrix;
         command.modelMatrix = modelMatrix;
@@ -242,6 +314,20 @@ void Renderer::renderWorld(Engine::Core::World* world) {
         // Use per-entity color if set, else a default
         // Use per-entity color if set, else a default
         command.color = QVector3D(renderable->color[0], renderable->color[1], renderable->color[2]);
         command.color = QVector3D(renderable->color[0], renderable->color[1], renderable->color[2]);
         submitRenderCommand(command);
         submitRenderCommand(command);
+
+        // If this render path drew a barracks (no custom renderer used), also draw rally flag
+        if (auto* unit = entity->getComponent<Engine::Core::UnitComponent>()) {
+            if (unit->unitType == "barracks") {
+                if (auto* prod = entity->getComponent<Engine::Core::ProductionComponent>()) {
+                    if (prod->rallySet && m_resources) {
+                        QMatrix4x4 flagModel;
+                        flagModel.translate(prod->rallyX, 0.1f, prod->rallyZ);
+                        flagModel.scale(0.2f, 0.2f, 0.2f);
+                        drawMeshColored(m_resources->unit(), flagModel, QVector3D(1.0f, 0.9f, 0.2f), m_resources->white());
+                    }
+                }
+            }
+        }
         // Selection ring is drawn by entity-specific renderer if desired
         // Selection ring is drawn by entity-specific renderer if desired
     }
     }
 }
 }

+ 2 - 0
render/gl/renderer.h

@@ -41,6 +41,7 @@ public:
     void setClearColor(float r, float g, float b, float a = 1.0f);
     void setClearColor(float r, float g, float b, float a = 1.0f);
     // Optional: inject an external ResourceManager owned by the app
     // Optional: inject an external ResourceManager owned by the app
     void setResources(const std::shared_ptr<ResourceManager>& resources) { m_resources = resources; }
     void setResources(const std::shared_ptr<ResourceManager>& resources) { m_resources = resources; }
+    void setHoveredBuildingId(unsigned int id) { m_hoveredBuildingId = id; }
 
 
     // Lightweight, app-facing helpers
     // Lightweight, app-facing helpers
     void renderGridGround();
     void renderGridGround();
@@ -86,6 +87,7 @@ private:
     // Default resources
     // Default resources
     std::shared_ptr<ResourceManager> m_resources;
     std::shared_ptr<ResourceManager> m_resources;
     std::unique_ptr<EntityRendererRegistry> m_entityRegistry;
     std::unique_ptr<EntityRendererRegistry> m_entityRegistry;
+    unsigned int m_hoveredBuildingId = 0;
 
 
     int m_viewportWidth = 0;
     int m_viewportWidth = 0;
     int m_viewportHeight = 0;
     int m_viewportHeight = 0;

+ 44 - 1
ui/qml/GameView.qml

@@ -12,6 +12,7 @@ Item {
     signal mapClicked(real x, real y)
     signal mapClicked(real x, real y)
     signal unitSelected(int unitId)
     signal unitSelected(int unitId)
     signal areaSelected(real x1, real y1, real x2, real y2)
     signal areaSelected(real x1, real y1, real x2, real y2)
+    property bool setRallyMode: false
     
     
     function setPaused(paused) {
     function setPaused(paused) {
         isPaused = paused
         isPaused = paused
@@ -102,6 +103,7 @@ Item {
             acceptedButtons: Qt.LeftButton | Qt.RightButton
             acceptedButtons: Qt.LeftButton | Qt.RightButton
             hoverEnabled: true
             hoverEnabled: true
             propagateComposedEvents: true
             propagateComposedEvents: true
+            preventStealing: true
             onWheel: function(w) {
             onWheel: function(w) {
                 // Mouse wheel: move camera up/down (RTS-style height adjust)
                 // Mouse wheel: move camera up/down (RTS-style height adjust)
                 // delta is in eighths of a degree; use angleDelta.y where available
                 // delta is in eighths of a degree; use angleDelta.y where available
@@ -119,6 +121,14 @@ Item {
             
             
             onPressed: function(mouse) {
             onPressed: function(mouse) {
                 if (mouse.button === Qt.LeftButton) {
                 if (mouse.button === Qt.LeftButton) {
+                    if (gameView.setRallyMode) {
+                        // In rally mode, a left click sets rally and does not start selection drag
+                        if (typeof game !== 'undefined' && game.setRallyAtScreen) {
+                            game.setRallyAtScreen(mouse.x, mouse.y)
+                        }
+                        gameView.setRallyMode = false
+                        return
+                    }
                     isSelecting = true
                     isSelecting = true
                     startX = mouse.x
                     startX = mouse.x
                     startY = mouse.y
                     startY = mouse.y
@@ -143,6 +153,27 @@ Item {
                     selectionBox.y = Math.min(startY, endY)
                     selectionBox.y = Math.min(startY, endY)
                     selectionBox.width = Math.abs(endX - startX)
                     selectionBox.width = Math.abs(endX - startX)
                     selectionBox.height = Math.abs(endY - startY)
                     selectionBox.height = Math.abs(endY - startY)
+                } else {
+                    if (typeof game !== 'undefined' && game.setHoverAtScreen) {
+                        // Debug: trace hover feed
+                        // console.log("hover move", mouse.x, mouse.y)
+                        game.setHoverAtScreen(mouse.x, mouse.y)
+                    }
+                }
+            }
+            onEntered: function() {
+                if (typeof game !== 'undefined' && game.setHoverAtScreen) {
+                    game.setHoverAtScreen(mouseArea.mouseX, mouseArea.mouseY)
+                }
+            }
+            onExited: function() {
+                if (typeof game !== 'undefined' && game.setHoverAtScreen) {
+                    game.setHoverAtScreen(-1, -1)
+                }
+            }
+            onContainsMouseChanged: function() {
+                if (!mouseArea.containsMouse && typeof game !== 'undefined' && game.setHoverAtScreen) {
+                    game.setHoverAtScreen(-1, -1)
                 }
                 }
             }
             }
             
             
@@ -163,7 +194,7 @@ Item {
                                                 false)
                                                 false)
                         }
                         }
                     } else {
                     } else {
-                        // Point selection
+                        // Point selection (unless rally was set in onPressed)
                         mapClicked(mouse.x, mouse.y)
                         mapClicked(mouse.x, mouse.y)
                         if (typeof game !== 'undefined' && game.onClickSelect) {
                         if (typeof game !== 'undefined' && game.onClickSelect) {
                             game.onClickSelect(mouse.x, mouse.y, false)
                             game.onClickSelect(mouse.x, mouse.y, false)
@@ -172,6 +203,18 @@ Item {
                 }
                 }
             }
             }
         }
         }
+
+        // Periodic hover updater in case position events are throttled by the scene graph
+        Timer {
+            interval: 33 // ~30 FPS is enough
+            running: true
+            repeat: true
+            onTriggered: {
+                if (mouseArea.containsMouse && typeof game !== 'undefined' && game.setHoverAtScreen) {
+                    game.setHoverAtScreen(mouseArea.mouseX, mouseArea.mouseY)
+                }
+            }
+        }
         
         
         // Selection box
         // Selection box
         Rectangle {
         Rectangle {

+ 100 - 8
ui/qml/HUD.qml

@@ -8,9 +8,26 @@ Item {
     signal pauseToggled()
     signal pauseToggled()
     signal speedChanged(real speed)
     signal speedChanged(real speed)
     signal unitCommand(string command)
     signal unitCommand(string command)
+    signal recruit(string unitType)
     
     
     property bool gameIsPaused: false
     property bool gameIsPaused: false
     property real currentSpeed: 1.0
     property real currentSpeed: 1.0
+    // Tick to refresh bindings when selection changes in engine
+    property int selectionTick: 0
+
+    Connections {
+        target: (typeof game !== 'undefined') ? game : null
+        function onSelectedUnitsChanged() { selectionTick += 1 }
+    }
+
+    // Periodic refresh to update production timers and counters while building
+    Timer {
+        id: productionRefresh
+        interval: 100
+        repeat: true
+        running: true
+        onTriggered: selectionTick += 1
+    }
     
     
     // Top panel
     // Top panel
     Rectangle {
     Rectangle {
@@ -280,19 +297,94 @@ Item {
                 Layout.fillWidth: true
                 Layout.fillWidth: true
             }
             }
             
             
-            // Production panel (placeholder)
+            // Production panel (contextual: shows when a Barracks is selected)
             Rectangle {
             Rectangle {
-                Layout.preferredWidth: 200
+                Layout.preferredWidth: 220
                 Layout.fillHeight: true
                 Layout.fillHeight: true
                 color: "#34495e"
                 color: "#34495e"
                 border.color: "#1a252f"
                 border.color: "#1a252f"
                 border.width: 1
                 border.width: 1
-                
-                Text {
-                    anchors.centerIn: parent
-                    text: "Building/Production"
-                    color: "#7f8c8d"
-                    font.pointSize: 10
+                Column {
+                    anchors.fill: parent
+                    anchors.margins: 8
+                    spacing: 6
+                    Text { id: prodHeader; text: "Production"; color: "white"; font.pointSize: 11; font.bold: true }
+                    ScrollView {
+                        id: prodScroll
+                        anchors.left: parent.left
+                        anchors.right: parent.right
+                        anchors.top: prodHeader.bottom
+                        anchors.bottom: parent.bottom
+                        clip: true
+                        ScrollBar.vertical.policy: ScrollBar.AlwaysOn
+                        Column {
+                            width: prodScroll.width
+                            spacing: 6
+                            // Show recruit buttons only when a barracks is in selection
+                            Repeater {
+                                // Include selectionTick in binding so we refresh when selection changes
+                                model: (selectionTick, (typeof game !== 'undefined' && game.hasSelectedType && game.hasSelectedType("barracks"))) ? 1 : 0
+                                delegate: Column {
+                                    spacing: 6
+                                    property var prod: (selectionTick, (typeof game !== 'undefined' && game.getSelectedProductionState) ? game.getSelectedProductionState() : ({}))
+                                    // Production button
+                                    Button {
+                                        id: recruitBtn
+                                        text: "Recruit Archer"
+                                        focusPolicy: Qt.NoFocus
+                                        enabled: (function(){
+                                            if (typeof prod === 'undefined' || !prod) return false
+                                            if (!prod.hasBarracks) return false
+                                            if (prod.inProgress) return false
+                                            if (prod.producedCount >= prod.maxUnits) return false
+                                            return true
+                                        })()
+                                        onClicked: recruit("archer")
+                                        onPressed: selectionTick += 1
+                                    }
+                                    // Progress bar for build timer
+                                    Rectangle {
+                                        width: 180; height: 8; radius: 4
+                                        color: "#1a252f"
+                                        border.color: "#2c3e50"; border.width: 1
+                                        visible: prod.inProgress
+                                        Rectangle {
+                                            anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter
+                                            height: parent.height
+                                            width: parent.width * (prod.buildTime > 0 ? (1.0 - Math.max(0, prod.timeRemaining) / prod.buildTime) : 0)
+                                            color: "#27ae60"
+                                            radius: 4
+                                        }
+                                    }
+                                    // Timer + population cap display
+                                    Row {
+                                        spacing: 8
+                                        Text { text: prod.inProgress ? ("Time left: " + Math.max(0, prod.timeRemaining).toFixed(1) + "s") : ("Build time: " + (prod.buildTime || 0).toFixed(0) + "s"); color: "#bdc3c7"; font.pointSize: 9 }
+                                        Text { text: (prod.producedCount || 0) + "/" + (prod.maxUnits || 0); color: "#bdc3c7"; font.pointSize: 9 }
+                                    }
+                                    // Cap reached message
+                                    Text { text: (prod.producedCount >= prod.maxUnits) ? "Cap reached" : ""; color: "#e67e22"; font.pointSize: 9 }
+
+                                    // Set Rally toggle: when active, next click in game view sets rally
+                                    Row {
+                                        spacing: 6
+                                        Button {
+                                            text: (typeof gameView !== 'undefined' && gameView.setRallyMode) ? "Click map: set rally (Esc to cancel)" : "Set Rally"
+                                            focusPolicy: Qt.NoFocus
+                                            enabled: !!prod.hasBarracks
+                                            onClicked: if (typeof gameView !== 'undefined') gameView.setRallyMode = !gameView.setRallyMode
+                                        }
+                                        Text { text: (typeof gameView !== 'undefined' && gameView.setRallyMode) ? "Click on the map" : ""; color: "#bdc3c7"; font.pointSize: 9 }
+                                    }
+                                }
+                            }
+                            // Fallback info if no production building
+                            Item { visible: (selectionTick, (typeof game === 'undefined' || !game.hasSelectedType || !game.hasSelectedType("barracks"))); 
+                                  anchors.horizontalCenter: parent.horizontalCenter; width: 1; height: 1;
+                                  Text { text: "No production"; color: "#7f8c8d"; anchors.horizontalCenter: parent.horizontalCenter; font.pointSize: 10 }
+                            }
+                        }
+                    }
                 }
                 }
             }
             }
         }
         }

+ 19 - 0
ui/qml/Main.qml

@@ -43,6 +43,13 @@ ApplicationWindow {
         onUnitCommand: function(command) {
         onUnitCommand: function(command) {
             gameViewItem.issueCommand(command)
             gameViewItem.issueCommand(command)
         }
         }
+
+        onRecruit: function(unitType) {
+            if (typeof game !== 'undefined' && game.recruitNearSelected)
+                game.recruitNearSelected(unitType)
+            // Return focus to the game view for keyboard controls
+            gameViewItem.forceActiveFocus()
+        }
     }
     }
 
 
     // Edge scroll overlay (hover-only) above HUD to ensure bottom edge works
     // Edge scroll overlay (hover-only) above HUD to ensure bottom edge works
@@ -65,14 +72,24 @@ ApplicationWindow {
             onPositionChanged: function(mouse) {
             onPositionChanged: function(mouse) {
                 edgeScrollOverlay.xPos = mouse.x
                 edgeScrollOverlay.xPos = mouse.x
                 edgeScrollOverlay.yPos = mouse.y
                 edgeScrollOverlay.yPos = mouse.y
+                // Also feed hover to the engine since this overlay sits above the GL view
+                if (typeof game !== 'undefined' && game.setHoverAtScreen) {
+                    game.setHoverAtScreen(mouse.x, mouse.y)
+                }
             }
             }
             onEntered: function(mouse) {
             onEntered: function(mouse) {
                 edgeScrollTimer.start()
                 edgeScrollTimer.start()
+                if (typeof game !== 'undefined' && game.setHoverAtScreen) {
+                    game.setHoverAtScreen(edgeScrollOverlay.xPos, edgeScrollOverlay.yPos)
+                }
             }
             }
             onExited: function(mouse) {
             onExited: function(mouse) {
                 edgeScrollTimer.stop()
                 edgeScrollTimer.stop()
                 edgeScrollOverlay.xPos = -1
                 edgeScrollOverlay.xPos = -1
                 edgeScrollOverlay.yPos = -1
                 edgeScrollOverlay.yPos = -1
+                if (typeof game !== 'undefined' && game.setHoverAtScreen) {
+                    game.setHoverAtScreen(-1, -1)
+                }
             }
             }
         }
         }
 
 
@@ -87,6 +104,8 @@ ApplicationWindow {
                 const x = edgeScrollOverlay.xPos
                 const x = edgeScrollOverlay.xPos
                 const y = edgeScrollOverlay.yPos
                 const y = edgeScrollOverlay.yPos
                 if (x < 0 || y < 0) return
                 if (x < 0 || y < 0) return
+                // Keep hover updated even if positionChanged throttles
+                if (game.setHoverAtScreen) game.setHoverAtScreen(x, y)
                 const t = edgeScrollOverlay.threshold
                 const t = edgeScrollOverlay.threshold
                 const clamp = function(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) }
                 const clamp = function(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) }
                 // Distance from edges
                 // Distance from edges