Przeglądaj źródła

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

djeada 2 miesięcy temu
rodzic
commit
07ece1399e

+ 295 - 8
app/game_engine.cpp

@@ -14,14 +14,17 @@
 #include "game/systems/combat_system.h"
 #include "game/systems/selection_system.h"
 #include "game/systems/arrow_system.h"
+#include "game/systems/production_system.h"
 #include "game/map/map_loader.h"
 #include "game/map/map_transformer.h"
 #include "game/visuals/visual_catalog.h"
 #include "game/units/factory.h"
+#include "game/units/unit.h"
 #include "game/map/environment.h"
 
 #include "selected_units_model.h"
 #include <cmath>
+#include <limits>
 GameEngine::GameEngine() {
     m_world    = std::make_unique<Engine::Core::World>();
     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::CombatSystem>());
     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_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) {
     if (!m_window || !m_selectionSystem) return;
     ensureInitialized();
     // 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>();
     for (auto* e : ents) {
         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 dy = float(sy) - float(sp.y());
         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 (!additive) m_selectionSystem->clearSelection();
         // Clicked near a unit: (re)select it
-        m_selectionSystem->selectUnit(bestId);
+        m_selectionSystem->selectUnit(bestUnitId);
         syncSelectionFlags();
         emit selectedUnitsChanged();
         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>();
     for (auto* e : ents) {
         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>();
         if (!u || u->ownerId != m_localOwnerId) continue; // only area-select friendlies
         auto* t = e->getComponent<Engine::Core::TransformComponent>();
@@ -225,6 +395,23 @@ void GameEngine::initialize() {
         } else {
             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 {
         qWarning() << "Map load failed:" << err << "- using fallback unit";
         Game::Map::Environment::applyDefault(*m_renderer, *m_camera);
@@ -244,6 +431,8 @@ void GameEngine::update(float dt) {
         dt *= m_timeScale;
     }
     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
     syncSelectionFlags();
     // 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_renderer->beginFrame();
+    // Provide hovered id for subtle outline
+    if (m_renderer) m_renderer->setHoveredBuildingId(m_hoveredBuildingId);
     m_renderer->renderWorld(m_world.get());
     // Render arrows
     if (m_arrowSystem) {
@@ -335,8 +526,10 @@ void GameEngine::setupFallbackTestUnit() {
 
 bool GameEngine::screenToGround(const QPointF& screenPt, QVector3D& outWorld) {
     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 {
@@ -453,3 +646,97 @@ void GameEngine::cameraSetFollowLerp(float alpha) {
 }
 
 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 <memory>
 #include <algorithm>
+#include <QVariant>
 
 namespace Engine { namespace Core {
 class World;
@@ -39,6 +40,7 @@ public:
     Q_INVOKABLE void onRightClick(qreal sx, qreal sy);
     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 setHoverAtScreen(qreal sx, qreal sy);
 
     // Camera controls exposed to QML
     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 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; }
 
     // Render-thread friendly calls (must be invoked when a valid GL context is current)
@@ -96,6 +104,9 @@ private:
     float m_camFar = 1000.0f;
     QObject* m_selectedUnitsModel = nullptr;
     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:
     void selectedUnitsChanged();
 

+ 2 - 0
app/selected_units_model.cpp

@@ -81,6 +81,8 @@ void SelectedUnitsModel::refresh() {
     } else {
         for (auto id : ids) {
             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 (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 vec3 u_color;
 uniform bool u_useTexture;
+uniform float u_alpha;
 
 out vec4 FragColor;
 
@@ -19,5 +20,5 @@ void main() {
     vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
     float diff = max(dot(normal, lightDir), 0.2);
     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/selection_system.cpp
     systems/arrow_system.cpp
+    systems/production_system.cpp
     map/map_loader.cpp
     map/map_transformer.cpp
     map/environment.cpp
@@ -25,6 +26,7 @@ add_library(game_systems STATIC
     units/unit.cpp
     units/archer.cpp
     units/factory.cpp
+    units/barracks.cpp
 )
 
 target_include_directories(game_systems PUBLIC .)

+ 26 - 0
game/core/component.h

@@ -72,3 +72,29 @@ public:
 };
 
 } // 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.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 "archer.h"
+#include "barracks.h"
 
 namespace Game { namespace Units {
 
@@ -7,6 +8,9 @@ void registerBuiltInUnits(UnitFactoryRegistry& reg) {
     reg.registerFactory("archer", [](Engine::Core::World& world, const SpawnParams& params){
         return Archer::Create(world, params);
     });
+    reg.registerFactory("barracks", [](Engine::Core::World& world, const SpawnParams& params){
+        return Barracks::Create(world, params);
+    });
 }
 
 } } // namespace Game::Units

+ 1 - 0
render/CMakeLists.txt

@@ -8,6 +8,7 @@ add_library(render_gl STATIC
     gl/resources.cpp
     entity/registry.cpp
     entity/archer_renderer.cpp
+    entity/barracks_renderer.cpp
     geom/selection_ring.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 "archer_renderer.h"
+#include "barracks_renderer.h"
 #include "../gl/renderer.h"
 #include "../../game/core/entity.h"
 #include "../../game/core/component.h"
@@ -18,6 +19,7 @@ RenderFunc EntityRendererRegistry::get(const std::string& type) const {
 
 void registerBuiltInEntityRenderers(EntityRendererRegistry& registry) {
     registerArcherRenderer(registry);
+    registerBarracksRenderer(registry);
 }
 
 } // 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<unsigned int> idx;
     const int seg = 48;
-    const float inner = 0.8f;
+    const float inner = 0.94f; // thinner ring band
     const float outer = 1.0f;
     for (int i = 0; i < seg; ++i) {
         float a0 = (i / float(seg)) * 6.2831853f;

+ 92 - 6
render/gl/renderer.cpp

@@ -6,6 +6,7 @@
 #include <algorithm>
 #include <cmath>
 #include "../entity/registry.h"
+#include "../geom/selection_ring.h"
 
 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_view", m_camera->getViewMatrix());
     m_basicShader->setUniform("u_projection", m_camera->getProjectionMatrix());
+    m_basicShader->setUniform("u_alpha", 1.0f);
     
     // Bind 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_view", m_camera->getViewMatrix());
     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) {
         texture->bind(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
     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
     auto renderableEntities = world->getEntitiesWith<Engine::Core::RenderableComponent>();
-    
+
     for (auto entity : renderableEntities) {
         auto renderable = entity->getComponent<Engine::Core::RenderableComponent>();
         auto transform = entity->getComponent<Engine::Core::TransformComponent>();
-        
+
         if (!renderable->visible || !transform) {
             continue;
         }
-        
+
         // Build model matrix from transform
         QMatrix4x4 modelMatrix;
         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.z, QVector3D(0, 0, 1));
         modelMatrix.scale(transform->scale.x, transform->scale.y, transform->scale.z);
-        
+
         // If entity has a unitType, try registry-based renderer first
         bool drawnByRegistry = false;
         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
         RenderCommand command;
         command.modelMatrix = modelMatrix;
@@ -242,6 +314,20 @@ void Renderer::renderWorld(Engine::Core::World* world) {
         // Use per-entity color if set, else a default
         command.color = QVector3D(renderable->color[0], renderable->color[1], renderable->color[2]);
         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
     }
 }

+ 2 - 0
render/gl/renderer.h

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

+ 44 - 1
ui/qml/GameView.qml

@@ -12,6 +12,7 @@ Item {
     signal mapClicked(real x, real y)
     signal unitSelected(int unitId)
     signal areaSelected(real x1, real y1, real x2, real y2)
+    property bool setRallyMode: false
     
     function setPaused(paused) {
         isPaused = paused
@@ -102,6 +103,7 @@ Item {
             acceptedButtons: Qt.LeftButton | Qt.RightButton
             hoverEnabled: true
             propagateComposedEvents: true
+            preventStealing: true
             onWheel: function(w) {
                 // Mouse wheel: move camera up/down (RTS-style height adjust)
                 // delta is in eighths of a degree; use angleDelta.y where available
@@ -119,6 +121,14 @@ Item {
             
             onPressed: function(mouse) {
                 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
                     startX = mouse.x
                     startY = mouse.y
@@ -143,6 +153,27 @@ Item {
                     selectionBox.y = Math.min(startY, endY)
                     selectionBox.width = Math.abs(endX - startX)
                     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)
                         }
                     } else {
-                        // Point selection
+                        // Point selection (unless rally was set in onPressed)
                         mapClicked(mouse.x, mouse.y)
                         if (typeof game !== 'undefined' && game.onClickSelect) {
                             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
         Rectangle {

+ 100 - 8
ui/qml/HUD.qml

@@ -8,9 +8,26 @@ Item {
     signal pauseToggled()
     signal speedChanged(real speed)
     signal unitCommand(string command)
+    signal recruit(string unitType)
     
     property bool gameIsPaused: false
     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
     Rectangle {
@@ -280,19 +297,94 @@ Item {
                 Layout.fillWidth: true
             }
             
-            // Production panel (placeholder)
+            // Production panel (contextual: shows when a Barracks is selected)
             Rectangle {
-                Layout.preferredWidth: 200
+                Layout.preferredWidth: 220
                 Layout.fillHeight: true
                 color: "#34495e"
                 border.color: "#1a252f"
                 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) {
             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
@@ -65,14 +72,24 @@ ApplicationWindow {
             onPositionChanged: function(mouse) {
                 edgeScrollOverlay.xPos = mouse.x
                 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) {
                 edgeScrollTimer.start()
+                if (typeof game !== 'undefined' && game.setHoverAtScreen) {
+                    game.setHoverAtScreen(edgeScrollOverlay.xPos, edgeScrollOverlay.yPos)
+                }
             }
             onExited: function(mouse) {
                 edgeScrollTimer.stop()
                 edgeScrollOverlay.xPos = -1
                 edgeScrollOverlay.yPos = -1
+                if (typeof game !== 'undefined' && game.setHoverAtScreen) {
+                    game.setHoverAtScreen(-1, -1)
+                }
             }
         }
 
@@ -87,6 +104,8 @@ ApplicationWindow {
                 const x = edgeScrollOverlay.xPos
                 const y = edgeScrollOverlay.yPos
                 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 clamp = function(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) }
                 // Distance from edges