Browse Source

group low-level state (runtime, viewport, level, hover) and adjust Q_PROPERTIES; route call sites to grouped structs

djeada 2 months ago
parent
commit
bfde977dd1

+ 113 - 511
app/game_engine.cpp

@@ -10,17 +10,21 @@
 #include "render/gl/renderer.h"
 #include "render/gl/camera.h"
 #include "render/gl/resources.h"
+#include "render/entity/arrow_vfx_renderer.h"
+#include "render/gl/bootstrap.h"
+#include "game/map/level_loader.h"
 #include "game/systems/movement_system.h"
 #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 "game/systems/picking_service.h"
+#include "game/systems/formation_planner.h"
+#include "game/systems/command_service.h"
+#include "game/systems/production_service.h"
+#include "game/systems/camera_follow_system.h"
+#include "game/systems/camera_controller.h"
+// Unused here after refactor; kept in loader/services
 
 #include "selected_units_model.h"
 #include <cmath>
@@ -42,15 +46,11 @@ GameEngine::GameEngine() {
     m_selectionSystem = std::make_unique<Game::Systems::SelectionSystem>();
     m_world->addSystem(std::make_unique<Game::Systems::SelectionSystem>());
 
-    // Expose internal pointers for models
-    setProperty("_worldPtr", QVariant::fromValue<void*>(m_world.get()));
-    // Selection system not yet constructed; set later after creation
-    // Defer actual entity creation until initialize() when GL and renderer are ready
-        setProperty("_selPtr", QVariant::fromValue<void*>(m_selectionSystem.get()));
-
-        // Create selected units model (owned by GameEngine)
+    // Create selected units model (owned by GameEngine)
         m_selectedUnitsModel = new SelectedUnitsModel(this, this);
         QMetaObject::invokeMethod(m_selectedUnitsModel, "refresh");
+    // Create picking service
+    m_pickingService = std::make_unique<Game::Systems::PickingService>();
 }
 
 GameEngine::~GameEngine() = default;
@@ -74,231 +74,25 @@ void GameEngine::onRightClick(qreal sx, qreal sy) {
     if (!m_selectionSystem) return;
     const auto& selected = m_selectionSystem->getSelectedUnits();
     if (selected.empty()) return;
-    // Simple formation: spread around click point
-    const float spacing = 1.0f;
-    int n = int(selected.size());
-    int side = std::ceil(std::sqrt(float(n)));
-    int i = 0;
-    for (auto id : selected) {
-        auto* e = m_world->getEntity(id);
-        if (!e) continue;
-        auto* mv = e->getComponent<Engine::Core::MovementComponent>();
-        if (!mv) e->addComponent<Engine::Core::MovementComponent>(), mv = e->getComponent<Engine::Core::MovementComponent>();
-        int gx = i % side;
-        int gy = i / side;
-        float ox = (gx - (side-1)*0.5f) * spacing;
-        float oz = (gy - (side-1)*0.5f) * spacing;
-        mv->targetX = hit.x() + ox;
-        mv->targetY = hit.z() + oz;
-        mv->hasTarget = true;
-        qInfo() << "  move-> id=" << e->getId() << "target (x,z)=" << mv->targetX << mv->targetY;
-        ++i;
-    }
+    auto targets = Game::Systems::FormationPlanner::spreadFormation(int(selected.size()), hit, 1.0f);
+    Game::Systems::CommandService::moveUnits(*m_world, selected, targets);
 }
 
 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 {
-        // Precompute the four corners in world space
-        QVector3D corners[4] = {
-            QVector3D(center.x() - hx, center.y(), center.z() - hz),
-            QVector3D(center.x() + hx, center.y(), center.z() - hz),
-            QVector3D(center.x() + hx, center.y(), center.z() + hz),
-            QVector3D(center.x() - hx, center.y(), center.z() + hz)
-        };
-        QPointF screenPts[4];
-        bool allOk = true;
-        for (int i = 0; i < 4; ++i) {
-            if (!worldToScreen(corners[i], screenPts[i])) {
-                allOk = false;
-                break;
-            }
-        }
-        if (!allOk) return false;
-        float minX = screenPts[0].x(), maxX = screenPts[0].x();
-        float minY = screenPts[0].y(), maxY = screenPts[0].y();
-        for (int i = 1; i < 4; ++i) {
-            minX = std::min(minX, float(screenPts[i].x()));
-            maxX = std::max(maxX, float(screenPts[i].x()));
-            minY = std::min(minY, float(screenPts[i].y()));
-            maxY = std::max(maxY, float(screenPts[i].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;
-    }
+    if (!m_pickingService || !m_camera || !m_world) return;
+    m_hover.buildingId = m_pickingService->updateHover(float(sx), float(sy), *m_world, *m_camera, m_viewport.width, m_viewport.height);
 }
 
 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 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;
-        auto* t = e->getComponent<Engine::Core::TransformComponent>();
-        auto* u = e->getComponent<Engine::Core::UnitComponent>();
-        if (!u || u->ownerId != m_localOwnerId) continue; // only select friendlies
-        QPointF sp;
-        if (!worldToScreen(QVector3D(t->position.x, t->position.y, t->position.z), sp)) continue;
-        float dx = float(sx) - float(sp.x());
-        float dy = float(sy) - float(sp.y());
-        float d2 = dx*dx + dy*dy;
-        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(); }
-        }
-    }
-    // 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 (!m_pickingService || !m_camera || !m_world) return;
+    Engine::Core::EntityID picked = m_pickingService->pickSingle(float(sx), float(sy), *m_world, *m_camera, m_viewport.width, m_viewport.height, m_runtime.localOwnerId, /*preferBuildingsFirst*/ true);
+    if (picked) {
         if (!additive) m_selectionSystem->clearSelection();
-        // Clicked near a unit: (re)select it
-        m_selectionSystem->selectUnit(bestUnitId);
+        m_selectionSystem->selectUnit(picked);
         syncSelectionFlags();
         emit selectedUnitsChanged();
         if (m_selectedUnitsModel) QMetaObject::invokeMethod(m_selectedUnitsModel, "refresh");
@@ -313,163 +107,57 @@ void GameEngine::onClickSelect(qreal sx, qreal sy, bool additive) {
             // Could not project to ground; nothing to do
             return;
         }
-        // Issue formation move similar to right-click
-        const float spacing = 1.0f;
-        int n = int(selected.size());
-        int side = std::ceil(std::sqrt(float(n)));
-        int i = 0;
-        for (auto id : selected) {
-            auto* e = m_world->getEntity(id);
-            if (!e) continue;
-            auto* mv = e->getComponent<Engine::Core::MovementComponent>();
-            if (!mv) e->addComponent<Engine::Core::MovementComponent>(), mv = e->getComponent<Engine::Core::MovementComponent>();
-            int gx = i % side;
-            int gy = i / side;
-            float ox = (gx - (side-1)*0.5f) * spacing;
-            float oz = (gy - (side-1)*0.5f) * spacing;
-            mv->targetX = hit.x() + ox;
-            mv->targetY = hit.z() + oz;
-            mv->hasTarget = true;
-            ++i;
-        }
+        auto targets = Game::Systems::FormationPlanner::spreadFormation(int(selected.size()), hit, 1.0f);
+        Game::Systems::CommandService::moveUnits(*m_world, selected, targets);
         // Keep existing selection; just ensure selection rings remain synced
         syncSelectionFlags();
         return;
     }
-
-    // Nothing selected and no unit clicked: clear any lingering visuals
-    if (!additive) m_selectionSystem->clearSelection();
-    syncSelectionFlags();
-    emit selectedUnitsChanged();
-    if (m_selectedUnitsModel) QMetaObject::invokeMethod(m_selectedUnitsModel, "refresh");
 }
 
 void GameEngine::onAreaSelected(qreal x1, qreal y1, qreal x2, qreal y2, bool additive) {
     if (!m_window || !m_selectionSystem) return;
     ensureInitialized();
     if (!additive) m_selectionSystem->clearSelection();
-    float minX = std::min(float(x1), float(x2));
-    float maxX = std::max(float(x1), float(x2));
-    float minY = std::min(float(y1), float(y2));
-    float maxY = std::max(float(y1), float(y2));
-    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>();
-        QPointF sp;
-        if (!worldToScreen(QVector3D(t->position.x, t->position.y, t->position.z), sp)) continue;
-        if (sp.x() >= minX && sp.x() <= maxX && sp.y() >= minY && sp.y() <= maxY) {
-            m_selectionSystem->selectUnit(e->getId());
-        }
-    }
+    if (!m_pickingService || !m_camera || !m_world) return;
+    auto picked = m_pickingService->pickInRect(float(x1), float(y1), float(x2), float(y2), *m_world, *m_camera, m_viewport.width, m_viewport.height, m_runtime.localOwnerId);
+    for (auto id : picked) m_selectionSystem->selectUnit(id);
     syncSelectionFlags();
     emit selectedUnitsChanged();
     if (m_selectedUnitsModel) QMetaObject::invokeMethod(m_selectedUnitsModel, "refresh");
 }
 
 void GameEngine::initialize() {
-    QOpenGLContext* ctx = QOpenGLContext::currentContext();
-    if (!ctx || !ctx->isValid()) {
-        qWarning() << "GameEngine::initialize called without a current, valid OpenGL context";
+    // Bootstrap rendering
+    if (!Render::GL::RenderBootstrap::initialize(*m_renderer, *m_camera, m_resources)) {
         return;
     }
-    // Create shared resources and inject to renderer before initialize
-    m_resources = std::make_shared<Render::GL::ResourceManager>();
-    m_renderer->setResources(m_resources);
-    if (!m_renderer->initialize()) {
-        qWarning() << "Failed to initialize renderer";
-        return;
-    }
-    m_renderer->setCamera(m_camera.get());
-    // Try load visuals JSON
-    Game::Visuals::VisualCatalog visualCatalog;
-    QString visualsErr;
-    visualCatalog.loadFromJsonFile("assets/visuals/unit_visuals.json", &visualsErr);
-    // Install unit factories
-    auto unitReg = std::make_shared<Game::Units::UnitFactoryRegistry>();
-    Game::Units::registerBuiltInUnits(*unitReg);
-    Game::Map::MapTransformer::setFactoryRegistry(unitReg);
-    // Try load map JSON
-    Game::Map::MapDefinition def;
+    // Load level and populate world
     QString mapPath = QString::fromUtf8("assets/maps/test_map.json");
-    QString err;
-    if (Game::Map::MapLoader::loadFromJsonFile(mapPath, def, &err)) {
-        m_loadedMapName = def.name;
-        // Delegate environment setup
-        Game::Map::Environment::apply(def, *m_renderer, *m_camera);
-        m_camFov = def.camera.fovY; m_camNear = def.camera.nearPlane; m_camFar = def.camera.farPlane;
-        // Populate world via transformer (which uses factories)
-        auto rt = Game::Map::MapTransformer::applyToWorld(def, *m_world, &visualCatalog);
-        if (!rt.unitIds.empty()) {
-            m_playerUnitId = rt.unitIds.front();
-        } 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);
-        m_camFov = m_camera->getFOV(); m_camNear = m_camera->getNear(); m_camFar = m_camera->getFar();
-        setupFallbackTestUnit();
-    }
-    m_initialized = true;
+    auto lr = Game::Map::LevelLoader::loadFromAssets(mapPath, *m_world, *m_renderer, *m_camera);
+    m_level.mapName = lr.mapName;
+    m_level.playerUnitId = lr.playerUnitId;
+    m_level.camFov = lr.camFov; m_level.camNear = lr.camNear; m_level.camFar = lr.camFar;
+    m_runtime.initialized = true;
 }
 
-void GameEngine::ensureInitialized() { if (!m_initialized) initialize(); }
+void GameEngine::ensureInitialized() { if (!m_runtime.initialized) initialize(); }
 
 void GameEngine::update(float dt) {
     // Apply pause and time scaling
-    if (m_paused) {
+    if (m_runtime.paused) {
         dt = 0.0f;
     } else {
-        dt *= m_timeScale;
+        dt *= m_runtime.timeScale;
     }
     if (m_world) m_world->update(dt);
-    // Decay hover grace window
-    if (m_hoverGraceTicks > 0) --m_hoverGraceTicks;
+    // Hover grace is now handled inside PickingService
     // Prune selection of dead units and keep flags in sync
     syncSelectionFlags();
     // Update camera follow behavior after world update so positions are fresh
     if (m_followSelectionEnabled && m_camera && m_selectionSystem && m_world) {
-        const auto& sel = m_selectionSystem->getSelectedUnits();
-        if (!sel.empty()) {
-            // Compute centroid of selected units
-            QVector3D sum(0,0,0); int count = 0;
-            for (auto id : sel) {
-                if (auto* e = m_world->getEntity(id)) {
-                    if (auto* t = e->getComponent<Engine::Core::TransformComponent>()) {
-                        sum += QVector3D(t->position.x, t->position.y, t->position.z);
-                        ++count;
-                    }
-                }
-            }
-            if (count > 0) {
-                QVector3D center = sum / float(count);
-                // Keep target in sync with centroid so orbit/yaw use the center
-                m_camera->setTarget(center);
-                m_camera->updateFollow(center);
-            }
-        }
+        Game::Systems::CameraFollowSystem cfs;
+        cfs.update(*m_world, *m_selectionSystem, *m_camera);
     }
 
     // Keep SelectedUnitsModel in sync with health changes even if selection IDs haven't changed
@@ -477,105 +165,54 @@ void GameEngine::update(float dt) {
 }
 
 void GameEngine::render(int pixelWidth, int pixelHeight) {
-    if (!m_renderer || !m_world || !m_initialized) return;
+    if (!m_renderer || !m_world || !m_runtime.initialized) return;
     if (pixelWidth > 0 && pixelHeight > 0) {
-        m_viewW = pixelWidth; m_viewH = pixelHeight;
+        m_viewport.width = pixelWidth; m_viewport.height = pixelHeight;
         m_renderer->setViewport(pixelWidth, pixelHeight);
-           float aspect = float(pixelWidth) / float(pixelHeight);
-           // Keep current camera fov/planes from map but update aspect
-           m_camera->setPerspective(m_camera->getFOV(), aspect, m_camera->getNear(), m_camera->getFar());
+    }
+    // Provide current selection to renderer for selection rings, without mutating ECS flags
+    if (m_selectionSystem) {
+        const auto& sel = m_selectionSystem->getSelectedUnits();
+        std::vector<unsigned int> ids(sel.begin(), sel.end());
+        m_renderer->setSelectedEntities(ids);
     }
     m_renderer->beginFrame();
     // Provide hovered id for subtle outline
-    if (m_renderer) m_renderer->setHoveredBuildingId(m_hoveredBuildingId);
+    if (m_renderer) m_renderer->setHoveredBuildingId(m_hover.buildingId);
     m_renderer->renderWorld(m_world.get());
-    // Render arrows
-    if (m_arrowSystem) {
-        for (const auto& arrow : m_arrowSystem->arrows()) {
-            if (!arrow.active) continue;
-            const QVector3D delta = arrow.end - arrow.start;
-            const float dist = std::max(0.001f, delta.length());
-            // Parabolic arc: height = arcHeight * 4 * t * (1-t)
-            QVector3D pos = arrow.start + delta * arrow.t;
-            float h = arrow.arcHeight * 4.0f * arrow.t * (1.0f - arrow.t);
-            pos.setY(pos.y() + h);
-            // Build model (constant visual length; thicker shaft)
-            QMatrix4x4 model;
-            model.translate(pos.x(), pos.y(), pos.z());
-            // Yaw around Y
-            QVector3D dir = delta.normalized();
-            float yawDeg = std::atan2(dir.x(), dir.z()) * 180.0f / 3.14159265f;
-            model.rotate(yawDeg, QVector3D(0,1,0));
-            // Pitch slightly down/up depending on arc to keep tip aligned
-            float vy = (arrow.end.y() - arrow.start.y()) / dist;
-            float pitchDeg = -std::atan2(vy - (8.0f * arrow.arcHeight * (arrow.t - 0.5f) / dist), 1.0f) * 180.0f / 3.14159265f;
-            model.rotate(pitchDeg, QVector3D(1,0,0));
-            const float zScale = 0.40f;     // even shorter constant arrow length
-            const float xyScale = 0.26f;    // even thicker shaft for visibility
-            // Center the arrow around its position: mesh runs 0..1 along +Z, so shift back by half length
-            model.translate(0.0f, 0.0f, -zScale * 0.5f);
-            model.scale(xyScale, xyScale, zScale);
-            m_renderer->drawMeshColored(m_resources->arrow(), model, arrow.color);
-        }
-    }
+    // Render arrows via entity-level VFX helper
+    if (m_arrowSystem) { Render::GL::renderArrows(m_renderer.get(), m_resources.get(), *m_arrowSystem); }
     m_renderer->endFrame();
 }
 
-void GameEngine::setupFallbackTestUnit() {
-    // Delegate fallback unit creation to the unit factory (archer)
-    auto reg = Game::Map::MapTransformer::getFactoryRegistry();
-    if (reg) {
-        Game::Units::SpawnParams sp;
-        sp.position = QVector3D(0.0f, 0.0f, 0.0f);
-        sp.playerId = 0;
-        sp.unitType = "archer";
-        if (auto unit = reg->create("archer", *m_world, sp)) {
-            m_playerUnitId = unit->id();
-            return;
-        }
-    }
-    // As a last resort, log and skip creating a unit to avoid mixing responsibilities here.
-    qWarning() << "setupFallbackTestUnit: No unit factory available for 'archer'; skipping fallback spawn";
-}
+// Removed fallback test unit setup (no longer used)
 
 bool GameEngine::screenToGround(const QPointF& screenPt, QVector3D& outWorld) {
-    if (!m_window || !m_camera) return false;
-    // 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);
+    if (!m_window || !m_camera || !m_pickingService) return false;
+    int w = (m_viewport.width > 0 ? m_viewport.width : m_window->width());
+    int h = (m_viewport.height > 0 ? m_viewport.height : m_window->height());
+    return m_pickingService->screenToGround(*m_camera, w, h, screenPt, outWorld);
 }
 
 bool GameEngine::worldToScreen(const QVector3D& world, QPointF& outScreen) const {
-    if (!m_camera || m_viewW <= 0 || m_viewH <= 0) return false;
-    return m_camera->worldToScreen(world, m_viewW, m_viewH, outScreen);
+    if (!m_camera || m_viewport.width <= 0 || m_viewport.height <= 0 || !m_pickingService) return false;
+    return m_pickingService->worldToScreen(*m_camera, m_viewport.width, m_viewport.height, world, outScreen);
 }
 
-void GameEngine::clearAllSelections() {
-    if (!m_world) return;
-    auto ents = m_world->getEntitiesWith<Engine::Core::UnitComponent>();
-    for (auto* e : ents) {
-        if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) u->selected = false;
-    }
-}
 
 void GameEngine::syncSelectionFlags() {
     if (!m_world || !m_selectionSystem) return;
-    clearAllSelections();
+    // Prune dead units from selection but don't mirror flags to components anymore
     const auto& sel = m_selectionSystem->getSelectedUnits();
     std::vector<Engine::Core::EntityID> toKeep;
     toKeep.reserve(sel.size());
     for (auto id : sel) {
         if (auto* e = m_world->getEntity(id)) {
             if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) {
-                if (u->health > 0) {
-                    u->selected = true;
-                    toKeep.push_back(id);
-                }
+                if (u->health > 0) toKeep.push_back(id);
             }
         }
     }
-    // If any dead units were filtered out, rebuild selection
     if (toKeep.size() != sel.size()) {
         m_selectionSystem->clearSelection();
         for (auto id : toKeep) m_selectionSystem->selectUnit(id);
@@ -586,61 +223,41 @@ void GameEngine::syncSelectionFlags() {
 void GameEngine::cameraMove(float dx, float dz) {
     ensureInitialized();
     if (!m_camera) return;
-    // Delegate to camera high-level pan
-    m_camera->pan(dx, dz);
-    // Manual control should take priority: keep follow enabled but update offset immediately
-    if (m_followSelectionEnabled) m_camera->captureFollowOffset();
+    Game::Systems::CameraController ctrl;
+    ctrl.move(*m_camera, dx, dz);
 }
 
 void GameEngine::cameraElevate(float dy) {
     ensureInitialized();
     if (!m_camera) return;
-    // Elevate via camera API
-    m_camera->elevate(dy);
-    if (m_followSelectionEnabled) m_camera->captureFollowOffset();
+    Game::Systems::CameraController ctrl;
+    ctrl.elevate(*m_camera, dy);
 }
 
 void GameEngine::cameraYaw(float degrees) {
     ensureInitialized();
     if (!m_camera) return;
-    // Rotate around target by yaw degrees
-    m_camera->yaw(degrees);
-    if (m_followSelectionEnabled) m_camera->captureFollowOffset();
+    Game::Systems::CameraController ctrl;
+    ctrl.yaw(*m_camera, degrees);
 }
 
 void GameEngine::cameraOrbit(float yawDeg, float pitchDeg) {
     ensureInitialized();
     if (!m_camera) return;
-    // Orbit around target via camera API
-    m_camera->orbit(yawDeg, pitchDeg);
-    if (m_followSelectionEnabled) m_camera->captureFollowOffset();
+    Game::Systems::CameraController ctrl;
+    ctrl.orbit(*m_camera, yawDeg, pitchDeg);
 }
 
 void GameEngine::cameraFollowSelection(bool enable) {
     ensureInitialized();
     m_followSelectionEnabled = enable;
-    if (m_camera) m_camera->setFollowEnabled(enable);
+    if (m_camera) {
+        Game::Systems::CameraController ctrl;
+        ctrl.setFollowEnabled(*m_camera, enable);
+    }
     if (enable && m_camera && m_selectionSystem && m_world) {
-        const auto& sel = m_selectionSystem->getSelectedUnits();
-        if (!sel.empty()) {
-            // compute immediate centroid and capture offset to maintain framing
-            QVector3D sum(0,0,0); int count = 0;
-            for (auto id : sel) {
-                if (auto* e = m_world->getEntity(id)) {
-                    if (auto* t = e->getComponent<Engine::Core::TransformComponent>()) {
-                        sum += QVector3D(t->position.x, t->position.y, t->position.z);
-                        ++count;
-                    }
-                }
-            }
-            if (count > 0) {
-                QVector3D target = sum / float(count);
-                // First set the new target, then capture offset relative to it
-                m_camera->setTarget(target);
-                // If no prior offset, capture one to keep current framing
-                m_camera->captureFollowOffset();
-            }
-        }
+        Game::Systems::CameraFollowSystem cfs;
+        cfs.snapToSelection(*m_world, *m_selectionSystem, *m_camera);
     } else if (m_camera) {
         // Follow disabled: ensure camera vectors are stable and keep current look direction
         // No target change; just recompute basis to avoid any drift
@@ -656,7 +273,8 @@ void GameEngine::cameraSetFollowLerp(float alpha) {
     ensureInitialized();
     if (!m_camera) return;
     float a = std::clamp(alpha, 0.0f, 1.0f);
-    m_camera->setFollowLerp(a);
+    Game::Systems::CameraController ctrl;
+    ctrl.setFollowLerp(*m_camera, a);
 }
 
 QObject* GameEngine::selectedUnitsModel() { return m_selectedUnitsModel; }
@@ -680,27 +298,7 @@ void GameEngine::recruitNearSelected(const QString& unitType) {
     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;
-            }
-        }
-    }
+    Game::Systems::ProductionService::startProductionForFirstSelectedBarracks(*m_world, sel, m_runtime.localOwnerId, unitType.toStdString());
 }
 
 QVariantMap GameEngine::getSelectedProductionState() const {
@@ -712,24 +310,14 @@ QVariantMap GameEngine::getSelectedProductionState() const {
     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;
-                }
-            }
-        }
-    }
+    Game::Systems::ProductionState st;
+    Game::Systems::ProductionService::getSelectedBarracksState(*m_world, m_selectionSystem->getSelectedUnits(), m_runtime.localOwnerId, st);
+    m["hasBarracks"] = st.hasBarracks;
+    m["inProgress"] = st.inProgress;
+    m["timeRemaining"] = st.timeRemaining;
+    m["buildTime"] = st.buildTime;
+    m["producedCount"] = st.producedCount;
+    m["maxUnits"] = st.maxUnits;
     return m;
 }
 
@@ -738,19 +326,33 @@ void GameEngine::setRallyAtScreen(qreal sx, qreal sy) {
     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;
-                    }
-                }
-            }
-        }
+    Game::Systems::ProductionService::setRallyForFirstSelectedBarracks(*m_world, m_selectionSystem->getSelectedUnits(), m_runtime.localOwnerId, hit.x(), hit.z());
+}
+
+// --- UI/View accessors ---
+void GameEngine::getSelectedUnitIds(std::vector<Engine::Core::EntityID>& out) const {
+    out.clear();
+    if (!m_selectionSystem) return;
+    const auto& ids = m_selectionSystem->getSelectedUnits();
+    out.assign(ids.begin(), ids.end());
+}
+
+bool GameEngine::getUnitInfo(Engine::Core::EntityID id, QString& name, int& health, int& maxHealth,
+                             bool& isBuilding, bool& alive) const {
+    if (!m_world) return false;
+    auto* e = m_world->getEntity(id);
+    if (!e) return false;
+    isBuilding = e->hasComponent<Engine::Core::BuildingComponent>();
+    if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) {
+        name = QString::fromStdString(u->unitType);
+        health = u->health;
+        maxHealth = u->maxHealth;
+        alive = (u->health > 0);
+        return true;
     }
+    // Non-unit entity
+    name = QStringLiteral("Entity");
+    health = maxHealth = 0;
+    alive = true;
+    return true;
 }

+ 27 - 27
app/game_engine.h

@@ -7,6 +7,7 @@
 #include <memory>
 #include <algorithm>
 #include <QVariant>
+#include <vector>
 
 namespace Engine { namespace Core {
 class World;
@@ -22,7 +23,7 @@ class Camera;
 class ResourceManager;
 } }
 
-namespace Game { namespace Systems { class SelectionSystem; class ArrowSystem; } }
+namespace Game { namespace Systems { class SelectionSystem; class ArrowSystem; class PickingService; } }
 
 class QQuickWindow;
 
@@ -33,8 +34,8 @@ public:
     ~GameEngine();
 
     Q_PROPERTY(QObject* selectedUnitsModel READ selectedUnitsModel NOTIFY selectedUnitsChanged)
-    Q_PROPERTY(bool paused MEMBER m_paused)
-    Q_PROPERTY(float timeScale MEMBER m_timeScale)
+    Q_PROPERTY(bool paused READ paused WRITE setPaused)
+    Q_PROPERTY(float timeScale READ timeScale WRITE setGameSpeed)
 
     Q_INVOKABLE void onMapClicked(qreal sx, qreal sy);
     Q_INVOKABLE void onRightClick(qreal sx, qreal sy);
@@ -51,8 +52,10 @@ public:
     Q_INVOKABLE void cameraSetFollowLerp(float alpha);    // 0..1, 1 = snap to center
 
     // Game loop control
-    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 setPaused(bool paused) { m_runtime.paused = paused; }
+    Q_INVOKABLE void setGameSpeed(float speed) { m_runtime.timeScale = std::max(0.0f, speed); }
+    bool paused() const { return m_runtime.paused; }
+    float timeScale() const { return m_runtime.timeScale; }
 
     // Selection queries and actions (for HUD/production)
     Q_INVOKABLE bool hasSelectedType(const QString& type) const;
@@ -67,13 +70,22 @@ public:
     void update(float dt);
     void render(int pixelWidth, int pixelHeight);
 
+    // Lightweight data accessors for UI/view models (avoid exposing raw pointers)
+    void getSelectedUnitIds(std::vector<Engine::Core::EntityID>& out) const;
+    bool getUnitInfo(Engine::Core::EntityID id, QString& name, int& health, int& maxHealth,
+                     bool& isBuilding, bool& alive) const;
+
 private:
+    // Small state groups to keep engine fields tidy
+    struct RuntimeState { bool initialized = false; bool paused = false; float timeScale = 1.0f; int localOwnerId = 1; };
+    struct ViewportState { int width = 0; int height = 0; };
+    struct LevelState { QString mapName; Engine::Core::EntityID playerUnitId = 0; float camFov = 45.0f; float camNear = 0.1f; float camFar = 1000.0f; };
+    struct HoverState { Engine::Core::EntityID buildingId = 0; };
+
     Game::Systems::ArrowSystem* m_arrowSystem = nullptr; // owned by world
     void initialize();
-    void setupFallbackTestUnit();
     bool screenToGround(const QPointF& screenPt, QVector3D& outWorld);
     bool worldToScreen(const QVector3D& world, QPointF& outScreen) const;
-    void clearAllSelections();
     void syncSelectionFlags();
     QObject* selectedUnitsModel();
 
@@ -82,31 +94,19 @@ private:
     std::unique_ptr<Render::GL::Camera>   m_camera;
     std::shared_ptr<Render::GL::ResourceManager> m_resources;
     std::unique_ptr<Game::Systems::SelectionSystem> m_selectionSystem;
+    std::unique_ptr<Game::Systems::PickingService> m_pickingService;
     QQuickWindow* m_window = nullptr;
-    Engine::Core::EntityID m_playerUnitId = 0;
-    bool m_initialized = false;
-    bool m_paused = false;
-    float m_timeScale = 1.0f;
-    int m_viewW = 0;
-    int m_viewH = 0;
+    RuntimeState m_runtime;
+    ViewportState m_viewport;
     // Follow behavior
     bool m_followSelectionEnabled = false;
-    QVector3D m_followOffset{0,0,0}; // position - target when follow enabled
-    float m_followLerp = 0.15f;      // smoothing factor [0..1]
-    // Map state
-    QString m_loadedMapName;
-    // Visual config (temporary; later load from assets)
-    struct UnitVisual { QVector3D color{0.8f,0.9f,1.0f}; };
-    UnitVisual m_visualArcher;
-    // Cached perspective parameters from map or defaults
-    float m_camFov = 45.0f;
-    float m_camNear = 0.1f;
-    float m_camFar = 1000.0f;
+    // Visual config placeholder removed; visuals are driven by render registry/services
+    // Level state
+    LevelState m_level;
     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
+    HoverState m_hover;
+    // Grace window now handled inside PickingService
 signals:
     void selectedUnitsChanged();
 

+ 14 - 40
app/selected_units_model.cpp

@@ -18,31 +18,13 @@ QVariant SelectedUnitsModel::data(const QModelIndex& index, int role) const {
     if (!index.isValid() || index.row() < 0 || index.row() >= static_cast<int>(m_ids.size())) return {};
     auto id = m_ids[index.row()];
     if (!m_engine) return {};
-    auto* world = reinterpret_cast<Engine::Core::World*>(m_engine->property("_worldPtr").value<void*>());
-    if (!world) return {};
-    auto* e = world->getEntity(id);
-    if (!e) return {};
+    QString name; int hp=0, maxHp=0; bool isB=false, alive=false;
     if (role == UnitIdRole) return QVariant::fromValue<int>(static_cast<int>(id));
-    if (role == NameRole) {
-        if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) return QString::fromStdString(u->unitType);
-        return QStringLiteral("Unit");
-    }
-    if (role == HealthRole) {
-        if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) return u->health;
-        return 0;
-    }
-    if (role == MaxHealthRole) {
-        if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) return u->maxHealth;
-        return 0;
-    }
-    if (role == HealthRatioRole) {
-        if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) {
-            if (u->maxHealth > 0) {
-                return static_cast<double>(std::clamp(u->health, 0, u->maxHealth)) / static_cast<double>(u->maxHealth);
-            }
-        }
-        return 0.0;
-    }
+    if (!m_engine->getUnitInfo(id, name, hp, maxHp, isB, alive)) return {};
+    if (role == NameRole) return name;
+    if (role == HealthRole) return hp;
+    if (role == MaxHealthRole) return maxHp;
+    if (role == HealthRatioRole) return (maxHp > 0 ? static_cast<double>(std::clamp(hp, 0, maxHp)) / static_cast<double>(maxHp) : 0.0);
     return {};
 }
 
@@ -58,9 +40,8 @@ QHash<int, QByteArray> SelectedUnitsModel::roleNames() const {
 
 void SelectedUnitsModel::refresh() {
     if (!m_engine) return;
-    auto* selSys = reinterpret_cast<Game::Systems::SelectionSystem*>(m_engine->property("_selPtr").value<void*>());
-    if (!selSys) return;
-    const auto& ids = selSys->getSelectedUnits();
+    std::vector<Engine::Core::EntityID> ids;
+    m_engine->getSelectedUnitIds(ids);
 
     // If the selected IDs are unchanged, emit dataChanged to refresh health ratios without resetting the list
     if (ids.size() == m_ids.size() && std::equal(ids.begin(), ids.end(), m_ids.begin())) {
@@ -75,19 +56,12 @@ void SelectedUnitsModel::refresh() {
     beginResetModel();
     // Filter out entities that are dead (health <= 0) if we can access the world
     m_ids.clear();
-    auto* world = reinterpret_cast<Engine::Core::World*>(m_engine->property("_worldPtr").value<void*>());
-    if (!world) {
-        m_ids.assign(ids.begin(), ids.end());
-    } 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);
-                }
-            }
-        }
+    for (auto id : ids) {
+        QString nm; int hp=0, maxHp=0; bool isB=false, alive=false;
+        if (!m_engine->getUnitInfo(id, nm, hp, maxHp, isB, alive)) continue;
+        if (isB) continue;
+        if (!alive) continue;
+        m_ids.push_back(id);
     }
     endResetModel();
 }

+ 6 - 0
game/CMakeLists.txt

@@ -18,8 +18,14 @@ add_library(game_systems STATIC
     systems/pathfinding.cpp
     systems/selection_system.cpp
     systems/arrow_system.cpp
+    systems/camera_follow_system.cpp
+    systems/camera_controller.cpp
+    systems/picking_service.cpp
+    systems/command_service.cpp
+    systems/production_service.cpp
     systems/production_system.cpp
     map/map_loader.cpp
+    map/level_loader.cpp
     map/map_transformer.cpp
     map/environment.cpp
     visuals/visual_catalog.cpp

+ 1 - 2
game/core/component.h

@@ -40,12 +40,11 @@ public:
 class UnitComponent : public Component {
 public:
     UnitComponent(int health = 100, int maxHealth = 100, float speed = 1.0f)
-        : health(health), maxHealth(maxHealth), speed(speed), selected(false), ownerId(0) {}
+        : health(health), maxHealth(maxHealth), speed(speed), ownerId(0) {}
 
     int health;
     int maxHealth;
     float speed;
-    bool selected;
     std::string unitType;
     int ownerId; // faction/player ownership
 };

+ 0 - 2
game/core/serialization.cpp

@@ -32,7 +32,6 @@ QJsonObject Serialization::serializeEntity(const Entity* entity) {
         unitObj["health"] = unit->health;
         unitObj["maxHealth"] = unit->maxHealth;
         unitObj["speed"] = unit->speed;
-        unitObj["selected"] = unit->selected;
         unitObj["unitType"] = QString::fromStdString(unit->unitType);
         unitObj["ownerId"] = unit->ownerId;
         entityObj["unit"] = unitObj;
@@ -62,7 +61,6 @@ void Serialization::deserializeEntity(Entity* entity, const QJsonObject& json) {
         unit->health = unitObj["health"].toInt();
         unit->maxHealth = unitObj["maxHealth"].toInt();
         unit->speed = unitObj["speed"].toDouble();
-    unit->selected = unitObj["selected"].toBool();
     unit->unitType = unitObj["unitType"].toString().toStdString();
     unit->ownerId = unitObj["ownerId"].toInt(0);
     }

+ 86 - 0
game/map/level_loader.cpp

@@ -0,0 +1,86 @@
+#include "level_loader.h"
+#include "map_loader.h"
+#include "map_transformer.h"
+#include "environment.h"
+#include "../visuals/visual_catalog.h"
+#include "../units/factory.h"
+#include "../core/world.h"
+#include "../core/component.h"
+#include "../../render/gl/renderer.h"
+#include "../../render/gl/camera.h"
+#include <QDebug>
+
+namespace Game { namespace Map {
+
+LevelLoadResult LevelLoader::loadFromAssets(const QString& mapPath,
+                                           Engine::Core::World& world,
+                                           Render::GL::Renderer& renderer,
+                                           Render::GL::Camera& camera) {
+    LevelLoadResult res;
+
+    // Load visuals JSON
+    Game::Visuals::VisualCatalog visualCatalog;
+    QString visualsErr;
+    visualCatalog.loadFromJsonFile("assets/visuals/unit_visuals.json", &visualsErr);
+
+    // Install unit factories
+    auto unitReg = std::make_shared<Game::Units::UnitFactoryRegistry>();
+    Game::Units::registerBuiltInUnits(*unitReg);
+    Game::Map::MapTransformer::setFactoryRegistry(unitReg);
+
+    // Try load map JSON
+    Game::Map::MapDefinition def;
+    QString err;
+    if (Game::Map::MapLoader::loadFromJsonFile(mapPath, def, &err)) {
+        res.ok = true;
+        res.mapName = def.name;
+        // Apply environment
+        Game::Map::Environment::apply(def, renderer, camera);
+        res.camFov = def.camera.fovY; res.camNear = def.camera.nearPlane; res.camFar = def.camera.farPlane;
+        // Populate world
+        auto rt = Game::Map::MapTransformer::applyToWorld(def, world, &visualCatalog);
+        if (!rt.unitIds.empty()) {
+            res.playerUnitId = rt.unitIds.front();
+        } else {
+            // Fallback: try to spawn archer at origin using registry
+            auto reg = Game::Map::MapTransformer::getFactoryRegistry();
+            if (reg) {
+                Game::Units::SpawnParams sp; sp.position = QVector3D(0.0f, 0.0f, 0.0f); sp.playerId = 0; sp.unitType = "archer";
+                if (auto unit = reg->create("archer", world, sp)) {
+                    res.playerUnitId = unit->id();
+                } else {
+                    qWarning() << "LevelLoader: Fallback archer spawn failed";
+                }
+            }
+        }
+        // Ensure there's at least one barracks for local player near origin
+        bool hasBarracks = false;
+        for (auto* e : world.getEntitiesWith<Engine::Core::UnitComponent>()) {
+            if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) {
+                if (u->unitType == "barracks" && u->ownerId == 1) { 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 = 1; sp.unitType = "barracks";
+                reg2->create("barracks", world, sp);
+            }
+        }
+    } else {
+        qWarning() << "LevelLoader: Map load failed:" << err << " - applying default environment";
+        Game::Map::Environment::applyDefault(renderer, camera);
+        res.ok = false;
+        res.camFov = camera.getFOV(); res.camNear = camera.getNear(); res.camFar = camera.getFar();
+        // Fallback archer spawn as last resort
+        auto reg = Game::Map::MapTransformer::getFactoryRegistry();
+        if (reg) {
+            Game::Units::SpawnParams sp; sp.position = QVector3D(0.0f, 0.0f, 0.0f); sp.playerId = 0; sp.unitType = "archer";
+            if (auto unit = reg->create("archer", world, sp)) { res.playerUnitId = unit->id(); }
+        }
+    }
+
+    return res;
+}
+
+} } // namespace Game::Map

+ 29 - 0
game/map/level_loader.h

@@ -0,0 +1,29 @@
+#pragma once
+
+#include <QString>
+#include <memory>
+
+namespace Engine { namespace Core { class World; using EntityID = unsigned int; } }
+namespace Render { namespace GL { class Renderer; class Camera; } }
+
+namespace Game { namespace Map {
+
+struct LevelLoadResult {
+    bool ok = false;
+    QString mapName;
+    Engine::Core::EntityID playerUnitId = 0;
+    float camFov = 45.0f;
+    float camNear = 0.1f;
+    float camFar = 1000.0f;
+};
+
+class LevelLoader {
+public:
+    // Loads visuals, installs unit factories, loads map, applies environment, populates world. Falls back to default env if needed.
+    static LevelLoadResult loadFromAssets(const QString& mapPath,
+                                          Engine::Core::World& world,
+                                          Render::GL::Renderer& renderer,
+                                          Render::GL::Camera& camera);
+};
+
+} } // namespace Game::Map

+ 34 - 0
game/systems/camera_controller.cpp

@@ -0,0 +1,34 @@
+#include "camera_controller.h"
+#include "../../render/gl/camera.h"
+
+namespace Game { namespace Systems {
+
+void CameraController::move(Render::GL::Camera& camera, float dx, float dz) const {
+    camera.pan(dx, dz);
+    if (camera.isFollowEnabled()) camera.captureFollowOffset();
+}
+
+void CameraController::elevate(Render::GL::Camera& camera, float dy) const {
+    camera.elevate(dy);
+    if (camera.isFollowEnabled()) camera.captureFollowOffset();
+}
+
+void CameraController::yaw(Render::GL::Camera& camera, float degrees) const {
+    camera.yaw(degrees);
+    if (camera.isFollowEnabled()) camera.captureFollowOffset();
+}
+
+void CameraController::orbit(Render::GL::Camera& camera, float yawDeg, float pitchDeg) const {
+    camera.orbit(yawDeg, pitchDeg);
+    if (camera.isFollowEnabled()) camera.captureFollowOffset();
+}
+
+void CameraController::setFollowEnabled(Render::GL::Camera& camera, bool enable) const {
+    camera.setFollowEnabled(enable);
+}
+
+void CameraController::setFollowLerp(Render::GL::Camera& camera, float alpha) const {
+    camera.setFollowLerp(alpha);
+}
+
+} } // namespace Game::Systems

+ 17 - 0
game/systems/camera_controller.h

@@ -0,0 +1,17 @@
+#pragma once
+
+namespace Render { namespace GL { class Camera; } }
+
+namespace Game { namespace Systems {
+
+class CameraController {
+public:
+    void move(Render::GL::Camera& camera, float dx, float dz) const;
+    void elevate(Render::GL::Camera& camera, float dy) const;
+    void yaw(Render::GL::Camera& camera, float degrees) const;
+    void orbit(Render::GL::Camera& camera, float yawDeg, float pitchDeg) const;
+    void setFollowEnabled(Render::GL::Camera& camera, bool enable) const;
+    void setFollowLerp(Render::GL::Camera& camera, float alpha) const;
+};
+
+} } // namespace Game::Systems

+ 51 - 0
game/systems/camera_follow_system.cpp

@@ -0,0 +1,51 @@
+#include "camera_follow_system.h"
+#include "selection_system.h"
+#include "../core/world.h"
+#include "../core/component.h"
+#include "../../render/gl/camera.h"
+
+namespace Game { namespace Systems {
+
+void CameraFollowSystem::update(Engine::Core::World& world,
+                                SelectionSystem& selection,
+                                Render::GL::Camera& camera) {
+    const auto& sel = selection.getSelectedUnits();
+    if (sel.empty()) return;
+    QVector3D sum(0,0,0); int count = 0;
+    for (auto id : sel) {
+        if (auto* e = world.getEntity(id)) {
+            if (auto* t = e->getComponent<Engine::Core::TransformComponent>()) {
+                sum += QVector3D(t->position.x, t->position.y, t->position.z);
+                ++count;
+            }
+        }
+    }
+    if (count > 0) {
+        QVector3D center = sum / float(count);
+        camera.setTarget(center);
+        camera.updateFollow(center);
+    }
+}
+
+void CameraFollowSystem::snapToSelection(Engine::Core::World& world,
+                                         SelectionSystem& selection,
+                                         Render::GL::Camera& camera) {
+    const auto& sel = selection.getSelectedUnits();
+    if (sel.empty()) return;
+    QVector3D sum(0,0,0); int count = 0;
+    for (auto id : sel) {
+        if (auto* e = world.getEntity(id)) {
+            if (auto* t = e->getComponent<Engine::Core::TransformComponent>()) {
+                sum += QVector3D(t->position.x, t->position.y, t->position.z);
+                ++count;
+            }
+        }
+    }
+    if (count > 0) {
+        QVector3D target = sum / float(count);
+        camera.setTarget(target);
+        camera.captureFollowOffset();
+    }
+}
+
+} } // namespace Game::Systems

+ 22 - 0
game/systems/camera_follow_system.h

@@ -0,0 +1,22 @@
+#pragma once
+
+#include <QVector3D>
+
+namespace Engine { namespace Core { class World; } }
+namespace Render { namespace GL { class Camera; } }
+namespace Game { namespace Systems { class SelectionSystem; } }
+
+namespace Game { namespace Systems {
+
+class CameraFollowSystem {
+public:
+    void update(Engine::Core::World& world,
+                SelectionSystem& selection,
+                Render::GL::Camera& camera);
+
+    void snapToSelection(Engine::Core::World& world,
+                         SelectionSystem& selection,
+                         Render::GL::Camera& camera);
+};
+
+} } // namespace Game::Systems

+ 24 - 0
game/systems/command_service.cpp

@@ -0,0 +1,24 @@
+#include "command_service.h"
+#include "../core/world.h"
+#include "../core/component.h"
+#include <QVector3D>
+
+namespace Game { namespace Systems {
+
+void CommandService::moveUnits(Engine::Core::World& world,
+                               const std::vector<Engine::Core::EntityID>& units,
+                               const std::vector<QVector3D>& targets) {
+    if (units.size() != targets.size()) return;
+    for (size_t i = 0; i < units.size(); ++i) {
+        auto* e = world.getEntity(units[i]);
+        if (!e) continue;
+        auto* mv = e->getComponent<Engine::Core::MovementComponent>();
+        if (!mv) mv = e->addComponent<Engine::Core::MovementComponent>();
+        if (!mv) continue;
+        mv->targetX = targets[i].x();
+        mv->targetY = targets[i].z();
+        mv->hasTarget = true;
+    }
+}
+
+} } // namespace Game::Systems

+ 22 - 0
game/systems/command_service.h

@@ -0,0 +1,22 @@
+#pragma once
+
+#include <vector>
+#include <QVector3D>
+
+namespace Engine { namespace Core {
+class World;
+using EntityID = unsigned int;
+struct MovementComponent;
+} }
+
+namespace Game { namespace Systems {
+
+class CommandService {
+public:
+    // Ensure movement components exist and assign targets for each entity to corresponding positions (same size vectors expected).
+    static void moveUnits(Engine::Core::World& world,
+                          const std::vector<Engine::Core::EntityID>& units,
+                          const std::vector<QVector3D>& targets);
+};
+
+} } // namespace Game::Systems

+ 27 - 0
game/systems/formation_planner.h

@@ -0,0 +1,27 @@
+#pragma once
+
+#include <QVector3D>
+#include <vector>
+#include <cmath>
+
+namespace Game { namespace Systems {
+
+class FormationPlanner {
+public:
+    // Simple square-grid spread around center, row-major, centered, with given spacing.
+    static std::vector<QVector3D> spreadFormation(int n, const QVector3D& center, float spacing = 1.0f) {
+        std::vector<QVector3D> out; out.reserve(n);
+        if (n <= 0) return out;
+        int side = std::ceil(std::sqrt(float(n)));
+        for (int i = 0; i < n; ++i) {
+            int gx = i % side;
+            int gy = i / side;
+            float ox = (gx - (side - 1) * 0.5f) * spacing;
+            float oz = (gy - (side - 1) * 0.5f) * spacing;
+            out.emplace_back(center.x() + ox, center.y(), center.z() + oz);
+        }
+        return out;
+    }
+};
+
+} } // namespace Game::Systems

+ 223 - 0
game/systems/picking_service.cpp

@@ -0,0 +1,223 @@
+#include "picking_service.h"
+#include "../../render/gl/camera.h"
+#include "../core/world.h"
+#include "../core/component.h"
+#include <algorithm>
+
+namespace Game { namespace Systems {
+
+bool PickingService::worldToScreen(const Render::GL::Camera& cam, int viewW, int viewH, const QVector3D& world, QPointF& out) const {
+    return cam.worldToScreen(world, viewW, viewH, out);
+}
+
+bool PickingService::screenToGround(const Render::GL::Camera& cam, int viewW, int viewH, const QPointF& screenPt, QVector3D& outWorld) const {
+    if (viewW <= 0 || viewH <= 0) return false;
+    return cam.screenToGround(float(screenPt.x()), float(screenPt.y()), float(viewW), float(viewH), outWorld);
+}
+
+bool PickingService::projectBounds(const Render::GL::Camera& cam, const QVector3D& center, float hx, float hz, int viewW, int viewH, QRectF& out) const {
+    QVector3D corners[4] = {
+        QVector3D(center.x() - hx, center.y(), center.z() - hz),
+        QVector3D(center.x() + hx, center.y(), center.z() - hz),
+        QVector3D(center.x() + hx, center.y(), center.z() + hz),
+        QVector3D(center.x() - hx, center.y(), center.z() + hz)
+    };
+    QPointF screenPts[4];
+    for (int i = 0; i < 4; ++i) {
+        if (!worldToScreen(cam, viewW, viewH, corners[i], screenPts[i])) return false;
+    }
+    float minX = screenPts[0].x(), maxX = screenPts[0].x();
+    float minY = screenPts[0].y(), maxY = screenPts[0].y();
+    for (int i = 1; i < 4; ++i) {
+        minX = std::min(minX, float(screenPts[i].x()));
+        maxX = std::max(maxX, float(screenPts[i].x()));
+        minY = std::min(minY, float(screenPts[i].y()));
+        maxY = std::max(maxY, float(screenPts[i].y()));
+    }
+    out = QRectF(QPointF(minX, minY), QPointF(maxX, maxY));
+    return true;
+}
+
+Engine::Core::EntityID PickingService::updateHover(float sx, float sy,
+                                                  Engine::Core::World& world,
+                                                  const Render::GL::Camera& camera,
+                                                  int viewW, int viewH) {
+    if (sx < 0 || sy < 0) {
+        m_prevHoverId = 0;
+        return 0;
+    }
+    auto prevHover = m_prevHoverId;
+    Engine::Core::EntityID currentHover = 0;
+
+    // Hysteresis check: keep previous hover if within expanded bounds
+    if (prevHover) {
+        if (auto* e = world.getEntity(prevHover)) {
+            if (e->hasComponent<Engine::Core::BuildingComponent>()) {
+                if (auto* t = e->getComponent<Engine::Core::TransformComponent>()) {
+                    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(camera, QVector3D(t->position.x, t->position.y, t->position.z), hxk, hzk, viewW, viewH, bounds)) {
+                        bounds.adjust(-pxPad, -pxPad, pxPad, pxPad);
+                        if (bounds.contains(QPointF(sx, sy))) {
+                            currentHover = prevHover;
+                            m_hoverGraceTicks = 6;
+                            m_prevHoverId = currentHover;
+                            return currentHover;
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    float bestD2 = std::numeric_limits<float>::max();
+    auto ents = 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>();
+        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(camera, QVector3D(t->position.x, t->position.y, t->position.z), hx, hz, viewW, viewH, bounds)) continue;
+        if (!bounds.contains(QPointF(sx, sy))) continue;
+        QPointF centerSp;
+    if (!worldToScreen(camera, viewW, viewH, 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; currentHover = e->getId(); }
+    }
+
+    if (currentHover != 0 && currentHover != prevHover) {
+        m_hoverGraceTicks = 6;
+    }
+
+    if (currentHover == 0 && prevHover != 0) {
+        if (auto* e = world.getEntity(prevHover)) {
+            auto* t = e->getComponent<Engine::Core::TransformComponent>();
+            if (t && e->getComponent<Engine::Core::BuildingComponent>()) {
+                const float marginXZ = 1.12f;
+                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(camera, QVector3D(t->position.x, t->position.y, t->position.z), hx, hz, viewW, viewH, bounds)) {
+                    if (bounds.contains(QPointF(sx, sy))) {
+                        currentHover = prevHover;
+                    }
+                }
+            }
+        }
+    }
+
+    if (currentHover == 0 && prevHover != 0 && m_hoverGraceTicks > 0) {
+        currentHover = prevHover;
+    }
+
+    if (m_hoverGraceTicks > 0) --m_hoverGraceTicks;
+    m_prevHoverId = currentHover;
+    return currentHover;
+}
+
+Engine::Core::EntityID PickingService::pickSingle(float sx, float sy,
+                                      Engine::Core::World& world,
+                                      const Render::GL::Camera& camera,
+                                      int viewW, int viewH,
+                                      int ownerFilter,
+                                      bool preferBuildingsFirst) const {
+    const float baseUnitPickRadius = 18.0f;
+    const float baseBuildingPickRadius = 28.0f;
+    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 = world.getEntitiesWith<Engine::Core::TransformComponent>();
+    for (auto* e : ents) {
+        if (!e->hasComponent<Engine::Core::UnitComponent>()) continue;
+        auto* t = e->getComponent<Engine::Core::TransformComponent>();
+        auto* u = e->getComponent<Engine::Core::UnitComponent>();
+        if (!u || u->ownerId != ownerFilter) continue;
+        QPointF sp;
+        if (!camera.worldToScreen(QVector3D(t->position.x, t->position.y, t->position.z), viewW, viewH, sp)) continue;
+        float dx = float(sx) - float(sp.x());
+        float dy = float(sy) - float(sp.y());
+        float d2 = dx*dx + dy*dy;
+        if (e->hasComponent<Engine::Core::BuildingComponent>()) {
+            bool hit = false; float pickDist2 = d2;
+            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);
+            QPointF pts[8]; int okCount=0;
+            auto project = [&](const QVector3D& w, QPointF& out){ return camera.worldToScreen(w, viewW, viewH, out); };
+            okCount += project(QVector3D(t->position.x - hx, t->position.y + 0.0f, t->position.z - hz), pts[0]);
+            okCount += project(QVector3D(t->position.x + hx, t->position.y + 0.0f, t->position.z - hz), pts[1]);
+            okCount += project(QVector3D(t->position.x + hx, t->position.y + 0.0f, t->position.z + hz), pts[2]);
+            okCount += project(QVector3D(t->position.x - hx, t->position.y + 0.0f, t->position.z + hz), pts[3]);
+            okCount += project(QVector3D(t->position.x - hx, t->position.y + hy,   t->position.z - hz), pts[4]);
+            okCount += project(QVector3D(t->position.x + hx, t->position.y + hy,   t->position.z - hz), pts[5]);
+            okCount += project(QVector3D(t->position.x + hx, t->position.y + hy,   t->position.z + hz), pts[6]);
+            okCount += project(QVector3D(t->position.x - hx, t->position.y + hy,   t->position.z + hz), pts[7]);
+            if (okCount == 8) {
+                float minX = pts[0].x(), maxX = pts[0].x();
+                float minY = pts[0].y(), maxY = pts[0].y();
+                for (int i=1;i<8;++i){ minX = std::min(minX, float(pts[i].x())); maxX = std::max(maxX, float(pts[i].x())); minY = std::min(minY, float(pts[i].y())); maxY = std::max(maxY, float(pts[i].y())); }
+                if (float(sx) >= minX && float(sx) <= maxX && float(sy) >= minY && float(sy) <= maxY) {
+                    hit = true; pickDist2 = d2;
+                }
+            }
+            if (!hit) {
+                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 (preferBuildingsFirst) {
+        if (bestBuildingId && (!bestUnitId || bestBuildingDist2 <= bestUnitDist2)) return bestBuildingId;
+        if (bestUnitId) return bestUnitId;
+    } else {
+        if (bestUnitId) return bestUnitId;
+        if (bestBuildingId) return bestBuildingId;
+    }
+    return 0;
+}
+
+std::vector<Engine::Core::EntityID> PickingService::pickInRect(float x1, float y1, float x2, float y2,
+                                                   Engine::Core::World& world,
+                                                   const Render::GL::Camera& camera,
+                                                   int viewW, int viewH,
+                                                   int ownerFilter) const {
+    float minX = std::min(x1, x2); float maxX = std::max(x1, x2);
+    float minY = std::min(y1, y2); float maxY = std::max(y1, y2);
+    std::vector<Engine::Core::EntityID> picked;
+    auto ents = world.getEntitiesWith<Engine::Core::TransformComponent>();
+    for (auto* e : ents) {
+        if (!e->hasComponent<Engine::Core::UnitComponent>()) continue;
+        if (e->hasComponent<Engine::Core::BuildingComponent>()) continue;
+        auto* u = e->getComponent<Engine::Core::UnitComponent>();
+        if (!u || u->ownerId != ownerFilter) continue;
+        auto* t = e->getComponent<Engine::Core::TransformComponent>();
+        QPointF sp; if (!camera.worldToScreen(QVector3D(t->position.x, t->position.y, t->position.z), viewW, viewH, sp)) continue;
+        if (sp.x() >= minX && sp.x() <= maxX && sp.y() >= minY && sp.y() <= maxY) {
+            picked.push_back(e->getId());
+        }
+    }
+    return picked;
+}
+
+} } // namespace Game::Systems

+ 55 - 0
game/systems/picking_service.h

@@ -0,0 +1,55 @@
+#pragma once
+
+#include <QPointF>
+#include <QRectF>
+#include <QVector3D>
+#include <limits>
+#include <vector>
+
+namespace Engine { namespace Core {
+class World;
+using EntityID = unsigned int;
+} }
+
+namespace Render { namespace GL { class Camera; } }
+
+namespace Game { namespace Systems {
+
+class PickingService {
+public:
+    PickingService() = default;
+
+    // Update hover state given screen coordinates; returns current hovered building id (or 0).
+    Engine::Core::EntityID updateHover(float sx, float sy,
+                                       Engine::Core::World& world,
+                                       const Render::GL::Camera& camera,
+                                       int viewW, int viewH);
+
+    // Projection helpers bound to the active camera/viewport
+    bool screenToGround(const Render::GL::Camera& camera, int viewW, int viewH,
+                        const QPointF& screenPt, QVector3D& outWorld) const;
+    bool worldToScreen(const Render::GL::Camera& camera, int viewW, int viewH,
+                        const QVector3D& world, QPointF& outScreen) const;
+
+    // Single click pick (returns 0 if none). Prefer buildings first toggles tie-breaking.
+    Engine::Core::EntityID pickSingle(float sx, float sy,
+                                      Engine::Core::World& world,
+                                      const Render::GL::Camera& camera,
+                                      int viewW, int viewH,
+                                      int ownerFilter,
+                                      bool preferBuildingsFirst) const;
+
+    // Rectangle selection pick; returns friendlies inside screen rect (excludes buildings).
+    std::vector<Engine::Core::EntityID> pickInRect(float x1, float y1, float x2, float y2,
+                                                   Engine::Core::World& world,
+                                                   const Render::GL::Camera& camera,
+                                                   int viewW, int viewH,
+                                                   int ownerFilter) const;
+
+private:
+    Engine::Core::EntityID m_prevHoverId = 0;
+    int m_hoverGraceTicks = 0;
+    bool projectBounds(const Render::GL::Camera& cam, const QVector3D& center, float hx, float hz, int viewW, int viewH, QRectF& out) const;
+};
+
+} } // namespace Game::Systems

+ 66 - 0
game/systems/production_service.cpp

@@ -0,0 +1,66 @@
+#include "production_service.h"
+#include "../core/world.h"
+#include "../core/component.h"
+
+namespace Game { namespace Systems {
+
+static Engine::Core::Entity* findFirstSelectedBarracks(Engine::Core::World& world,
+                                                       const std::vector<Engine::Core::EntityID>& selected,
+                                                       int ownerId) {
+    for (auto id : selected) {
+        if (auto* e = world.getEntity(id)) {
+            auto* u = e->getComponent<Engine::Core::UnitComponent>();
+            if (!u || u->ownerId != ownerId) continue;
+            if (u->unitType == "barracks") return e;
+        }
+    }
+    return nullptr;
+}
+
+bool ProductionService::startProductionForFirstSelectedBarracks(Engine::Core::World& world,
+                                                                const std::vector<Engine::Core::EntityID>& selected,
+                                                                int ownerId,
+                                                                const std::string& unitType) {
+    auto* e = findFirstSelectedBarracks(world, selected, ownerId);
+    if (!e) return false;
+    auto* p = e->getComponent<Engine::Core::ProductionComponent>();
+    if (!p) p = e->addComponent<Engine::Core::ProductionComponent>();
+    if (!p) return false;
+    if (p->producedCount >= p->maxUnits) return false;
+    if (p->inProgress) return false;
+    p->productType = unitType;
+    p->timeRemaining = p->buildTime;
+    p->inProgress = true;
+    return true;
+}
+
+bool ProductionService::setRallyForFirstSelectedBarracks(Engine::Core::World& world,
+                                                         const std::vector<Engine::Core::EntityID>& selected,
+                                                         int ownerId,
+                                                         float x, float z) {
+    auto* e = findFirstSelectedBarracks(world, selected, ownerId);
+    if (!e) return false;
+    auto* p = e->getComponent<Engine::Core::ProductionComponent>();
+    if (!p) p = e->addComponent<Engine::Core::ProductionComponent>();
+    if (!p) return false;
+    p->rallyX = x; p->rallyZ = z; p->rallySet = true; return true;
+}
+
+bool ProductionService::getSelectedBarracksState(Engine::Core::World& world,
+                                                 const std::vector<Engine::Core::EntityID>& selected,
+                                                 int ownerId,
+                                                 ProductionState& outState) {
+    auto* e = findFirstSelectedBarracks(world, selected, ownerId);
+    if (!e) { outState = {}; return false; }
+    outState.hasBarracks = true;
+    if (auto* p = e->getComponent<Engine::Core::ProductionComponent>()) {
+        outState.inProgress = p->inProgress;
+        outState.timeRemaining = p->timeRemaining;
+        outState.buildTime = p->buildTime;
+        outState.producedCount = p->producedCount;
+        outState.maxUnits = p->maxUnits;
+    }
+    return true;
+}
+
+} } // namespace Game::Systems

+ 40 - 0
game/systems/production_service.h

@@ -0,0 +1,40 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+namespace Engine { namespace Core {
+class World;
+using EntityID = unsigned int;
+} }
+
+namespace Game { namespace Systems {
+
+struct ProductionState {
+    bool hasBarracks = false;
+    bool inProgress = false;
+    float timeRemaining = 0.0f;
+    float buildTime = 0.0f;
+    int producedCount = 0;
+    int maxUnits = 0;
+};
+
+class ProductionService {
+public:
+    static bool startProductionForFirstSelectedBarracks(Engine::Core::World& world,
+                                                       const std::vector<Engine::Core::EntityID>& selected,
+                                                       int ownerId,
+                                                       const std::string& unitType);
+
+    static bool setRallyForFirstSelectedBarracks(Engine::Core::World& world,
+                                                 const std::vector<Engine::Core::EntityID>& selected,
+                                                 int ownerId,
+                                                 float x, float z);
+
+    static bool getSelectedBarracksState(Engine::Core::World& world,
+                                         const std::vector<Engine::Core::EntityID>& selected,
+                                         int ownerId,
+                                         ProductionState& outState);
+};
+
+} } // namespace Game::Systems

+ 0 - 12
game/units/unit.cpp

@@ -31,18 +31,6 @@ void Unit::moveTo(float x, float z) {
     if (m_mv) { m_mv->targetX = x; m_mv->targetY = z; m_mv->hasTarget = true; }
 }
 
-void Unit::setSelected(bool sel) {
-    ensureCoreComponents();
-    if (m_u) m_u->selected = sel;
-}
-
-bool Unit::isSelected() const {
-    if (auto* e = entity()) {
-        if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) return u->selected;
-    }
-    return false;
-}
-
 bool Unit::isAlive() const {
     if (auto* e = entity()) {
         if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) return u->health > 0;

+ 0 - 2
game/units/unit.h

@@ -29,8 +29,6 @@ public:
 
     // Convenience controls that mutate ECS components
     void moveTo(float x, float z);
-    void setSelected(bool sel);
-    bool isSelected() const;
     bool isAlive() const;
     QVector3D position() const;
 

+ 2 - 0
render/CMakeLists.txt

@@ -6,9 +6,11 @@ add_library(render_gl STATIC
     gl/renderer.cpp
     gl/camera.cpp
     gl/resources.cpp
+    gl/bootstrap.cpp
     entity/registry.cpp
     entity/archer_renderer.cpp
     entity/barracks_renderer.cpp
+    entity/arrow_vfx_renderer.cpp
     geom/selection_ring.cpp
     geom/arrow.cpp
 )

+ 1 - 1
render/entity/archer_renderer.cpp

@@ -101,7 +101,7 @@ void registerArcherRenderer(EntityRendererRegistry& registry) {
         // Draw capsule (archer body)
         p.renderer->drawMeshColored(getArcherCapsule(), p.model, color, nullptr);
         // Draw selection ring if selected
-        if (unit && unit->selected) {
+        if (p.selected) {
             QMatrix4x4 ringM;
             QVector3D pos = p.model.column(3).toVector3D();
             ringM.translate(pos.x(), 0.01f, pos.z());

+ 39 - 0
render/entity/arrow_vfx_renderer.cpp

@@ -0,0 +1,39 @@
+#include "arrow_vfx_renderer.h"
+#include "registry.h"
+#include "../gl/renderer.h"
+#include "../gl/resources.h"
+#include "../../game/systems/arrow_system.h"
+#include <algorithm>
+#include <cmath>
+
+namespace Render::GL {
+
+void renderArrows(Renderer* renderer, ResourceManager* resources, const Game::Systems::ArrowSystem& arrowSystem) {
+    if (!renderer || !resources) return;
+    auto* arrowMesh = resources->arrow();
+    if (!arrowMesh) return;
+    const auto& arrows = arrowSystem.arrows();
+    for (const auto& arrow : arrows) {
+        if (!arrow.active) continue;
+        const QVector3D delta = arrow.end - arrow.start;
+        const float dist = std::max(0.001f, delta.length());
+        QVector3D pos = arrow.start + delta * arrow.t;
+        float h = arrow.arcHeight * 4.0f * arrow.t * (1.0f - arrow.t);
+        pos.setY(pos.y() + h);
+        QMatrix4x4 model;
+        model.translate(pos.x(), pos.y(), pos.z());
+        QVector3D dir = delta.normalized();
+        float yawDeg = std::atan2(dir.x(), dir.z()) * 180.0f / 3.14159265f;
+        model.rotate(yawDeg, QVector3D(0,1,0));
+        float vy = (arrow.end.y() - arrow.start.y()) / dist;
+        float pitchDeg = -std::atan2(vy - (8.0f * arrow.arcHeight * (arrow.t - 0.5f) / dist), 1.0f) * 180.0f / 3.14159265f;
+        model.rotate(pitchDeg, QVector3D(1,0,0));
+        const float zScale = 0.40f;
+        const float xyScale = 0.26f;
+        model.translate(0.0f, 0.0f, -zScale * 0.5f);
+        model.scale(xyScale, xyScale, zScale);
+        renderer->drawMeshColored(arrowMesh, model, arrow.color);
+    }
+}
+
+} // namespace Render::GL

+ 14 - 0
render/entity/arrow_vfx_renderer.h

@@ -0,0 +1,14 @@
+#pragma once
+
+#include <QMatrix4x4>
+#include <QVector3D>
+
+namespace Render { namespace GL { class Renderer; class ResourceManager; } }
+namespace Game { namespace Systems { class ArrowSystem; } }
+
+namespace Render::GL {
+
+// Draws projectile arrows from ArrowSystem using the shared Renderer/ResourceManager.
+void renderArrows(Renderer* renderer, ResourceManager* resources, const Game::Systems::ArrowSystem& arrowSystem);
+
+} // namespace Render::GL

+ 1 - 0
render/entity/registry.h

@@ -17,6 +17,7 @@ struct DrawParams {
     ResourceManager* resources = nullptr;
     Engine::Core::Entity* entity = nullptr;
     QMatrix4x4 model;
+    bool selected = false;
 };
 
 using RenderFunc = std::function<void(const DrawParams&)>;

+ 28 - 0
render/gl/bootstrap.cpp

@@ -0,0 +1,28 @@
+#include "bootstrap.h"
+#include "renderer.h"
+#include "camera.h"
+#include "resources.h"
+#include <QOpenGLContext>
+#include <QDebug>
+
+namespace Render { namespace GL {
+
+bool RenderBootstrap::initialize(Renderer& renderer,
+                                 Camera& camera,
+                                 std::shared_ptr<ResourceManager>& outResources) {
+    QOpenGLContext* ctx = QOpenGLContext::currentContext();
+    if (!ctx || !ctx->isValid()) {
+        qWarning() << "RenderBootstrap: no current valid OpenGL context";
+        return false;
+    }
+    outResources = std::make_shared<ResourceManager>();
+    renderer.setResources(outResources);
+    if (!renderer.initialize()) {
+        qWarning() << "RenderBootstrap: renderer initialize failed";
+        return false;
+    }
+    renderer.setCamera(&camera);
+    return true;
+}
+
+} } // namespace Render::GL

+ 20 - 0
render/gl/bootstrap.h

@@ -0,0 +1,20 @@
+#pragma once
+
+#include <memory>
+
+class QOpenGLContext;
+
+namespace Render { namespace GL {
+class Renderer;
+class Camera;
+class ResourceManager;
+
+class RenderBootstrap {
+public:
+    // Initializes GL renderer with resources and binds camera. Returns false if no valid GL context or renderer init fails.
+    static bool initialize(Renderer& renderer,
+                           Camera& camera,
+                           std::shared_ptr<ResourceManager>& outResources);
+};
+
+} } // namespace Render::GL

+ 6 - 0
render/gl/renderer.cpp

@@ -104,6 +104,10 @@ void Renderer::setClearColor(float r, float g, float b, float a) {
 void Renderer::setViewport(int width, int height) {
     m_viewportWidth = width;
     m_viewportHeight = height;
+    if (m_camera && height > 0) {
+        float aspect = float(width) / float(height);
+        m_camera->setPerspective(m_camera->getFOV(), aspect, m_camera->getNear(), m_camera->getFar());
+    }
 }
 
 void Renderer::drawMesh(Mesh* mesh, const QMatrix4x4& modelMatrix, Texture* texture) {
@@ -270,6 +274,8 @@ void Renderer::renderWorld(Engine::Core::World* world) {
                 auto fn = m_entityRegistry->get(unit->unitType);
                 if (fn) {
                     DrawParams params{this, m_resources.get(), entity, modelMatrix};
+                    // Selection routed from app via setSelectedEntities to avoid mutating ECS flags for rendering
+                    params.selected = (m_selectedIds.find(entity->getId()) != m_selectedIds.end());
                     fn(params);
                     drawnByRegistry = true;
                 }

+ 13 - 0
render/gl/renderer.h

@@ -9,6 +9,7 @@
 #include <memory>
 #include <vector>
 #include <optional>
+#include <unordered_set>
 
 namespace Engine::Core {
 class World;
@@ -17,6 +18,11 @@ class Entity;
 
 namespace Render::GL {
 class EntityRendererRegistry;
+}
+
+namespace Game { namespace Systems { class ArrowSystem; } }
+
+namespace Render::GL {
 
 struct RenderCommand {
     Mesh* mesh = nullptr;
@@ -42,9 +48,15 @@ public:
     // 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; }
+    // Selection information provided by the app before rendering
+    void setSelectedEntities(const std::vector<unsigned int>& ids) {
+        m_selectedIds.clear();
+        m_selectedIds.insert(ids.begin(), ids.end());
+    }
 
     // Lightweight, app-facing helpers
     void renderGridGround();
+    // High-level helpers are provided in render/entity (see arrow_vfx_renderer)
 
     // Read-only access to default meshes/textures for app-side batching
     Mesh* getMeshQuad()    const { return m_resources ? m_resources->quad()    : nullptr; }
@@ -88,6 +100,7 @@ private:
     std::shared_ptr<ResourceManager> m_resources;
     std::unique_ptr<EntityRendererRegistry> m_entityRegistry;
     unsigned int m_hoveredBuildingId = 0;
+    std::unordered_set<unsigned int> m_selectedIds; // for selection rings at render time
 
     int m_viewportWidth = 0;
     int m_viewportHeight = 0;

+ 3 - 0
ui/qml/HUD.qml

@@ -12,6 +12,9 @@ Item {
     
     property bool gameIsPaused: false
     property real currentSpeed: 1.0
+    // Expose panel heights for other overlays (e.g., edge scroll) to avoid stealing input over UI
+    property int topPanelHeight: topPanel.height
+    property int bottomPanelHeight: bottomPanel.height
     // Tick to refresh bindings when selection changes in engine
     property int selectionTick: 0
 

+ 34 - 5
ui/qml/Main.qml

@@ -61,6 +61,17 @@ ApplicationWindow {
         property real maxSpeed: 1.2    // world units per tick at the very edge
         property real xPos: -1
         property real yPos: -1
+    // Shift vertical edge-scroll away from HUD panels by this many pixels
+    property int verticalShift: 6
+        // Computed guard zones derived from HUD panel heights
+        function inHudZone(x, y) {
+            // Guard against missing HUD object
+            var topH = (typeof hud !== 'undefined' && hud && hud.topPanelHeight) ? hud.topPanelHeight : 0
+            var bottomH = (typeof hud !== 'undefined' && hud && hud.bottomPanelHeight) ? hud.bottomPanelHeight : 0
+            if (y < topH) return true
+            if (y > (height - bottomH)) return true
+            return false
+        }
 
         // Hover tracker that does not consume clicks
         MouseArea {
@@ -72,15 +83,23 @@ 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
+                // Only feed hover to engine if not over HUD panels; otherwise clear hover
                 if (typeof game !== 'undefined' && game.setHoverAtScreen) {
-                    game.setHoverAtScreen(mouse.x, mouse.y)
+                    if (!edgeScrollOverlay.inHudZone(mouse.x, mouse.y)) {
+                        game.setHoverAtScreen(mouse.x, mouse.y)
+                    } else {
+                        game.setHoverAtScreen(-1, -1)
+                    }
                 }
             }
             onEntered: function(mouse) {
                 edgeScrollTimer.start()
                 if (typeof game !== 'undefined' && game.setHoverAtScreen) {
-                    game.setHoverAtScreen(edgeScrollOverlay.xPos, edgeScrollOverlay.yPos)
+                    if (!edgeScrollOverlay.inHudZone(edgeScrollOverlay.xPos, edgeScrollOverlay.yPos)) {
+                        game.setHoverAtScreen(edgeScrollOverlay.xPos, edgeScrollOverlay.yPos)
+                    } else {
+                        game.setHoverAtScreen(-1, -1)
+                    }
                 }
             }
             onExited: function(mouse) {
@@ -104,6 +123,11 @@ ApplicationWindow {
                 const x = edgeScrollOverlay.xPos
                 const y = edgeScrollOverlay.yPos
                 if (x < 0 || y < 0) return
+                // Skip camera movement and hover updates when over HUD panels
+                if (edgeScrollOverlay.inHudZone(x, y)) {
+                    if (game.setHoverAtScreen) game.setHoverAtScreen(-1, -1)
+                    return
+                }
                 // Keep hover updated even if positionChanged throttles
                 if (game.setHoverAtScreen) game.setHoverAtScreen(x, y)
                 const t = edgeScrollOverlay.threshold
@@ -111,8 +135,13 @@ ApplicationWindow {
                 // Distance from edges
                 const dl = x
                 const dr = w - x
-                const dt = y
-                const db = h - y
+                // Shift vertical edges inward to avoid overlapping HUD panels
+                const topBar = (typeof hud !== 'undefined' && hud && hud.topPanelHeight) ? hud.topPanelHeight : 0
+                const bottomBar = (typeof hud !== 'undefined' && hud && hud.bottomPanelHeight) ? hud.bottomPanelHeight : 0
+                const topEdge = topBar + edgeScrollOverlay.verticalShift
+                const bottomEdge = h - bottomBar - edgeScrollOverlay.verticalShift
+                const dt = Math.max(0, y - topEdge)
+                const db = Math.max(0, bottomEdge - y)
                 // Normalized intensities (0..1)
                 const il = clamp(1.0 - dl / t, 0, 1)
                 const ir = clamp(1.0 - dr / t, 0, 1)