Ver Fonte

improve unit selection and coordination

djeada há 2 meses atrás
pai
commit
6018676930
64 ficheiros alterados com 1933 adições e 730 exclusões
  1. 5 3
      CMakeLists.txt
  2. 1 1
      README.md
  3. 343 70
      app/game_engine.cpp
  4. 30 1
      app/game_engine.h
  5. 91 0
      app/selected_units_model.cpp
  6. 26 0
      app/selected_units_model.h
  7. 11 1
      assets/maps/test_map.json
  8. 0 41
      assets/maps/test_map.txt
  9. 17 29
      assets/shaders/basic.frag
  10. 14 16
      assets/shaders/basic.vert
  11. 22 0
      assets/shaders/grid.frag
  12. 0 11
      engine/CMakeLists.txt
  13. 0 7
      engine/core/entity.cpp
  14. 0 82
      engine/core/entity.h
  15. 0 7
      engine/core/event_manager.cpp
  16. 0 7
      engine/core/system.cpp
  17. 19 1
      game/CMakeLists.txt
  18. 1 1
      game/core/component.cpp
  19. 19 8
      game/core/component.h
  20. 13 0
      game/core/entity.cpp
  21. 68 0
      game/core/entity.h
  22. 7 0
      game/core/event_manager.cpp
  23. 17 26
      game/core/event_manager.h
  24. 7 14
      game/core/serialization.cpp
  25. 1 1
      game/core/serialization.h
  26. 7 0
      game/core/system.cpp
  27. 1 3
      game/core/system.h
  28. 1 1
      game/core/world.cpp
  29. 13 13
      game/core/world.h
  30. 26 0
      game/map/environment.cpp
  31. 15 0
      game/map/environment.h
  32. 6 0
      game/map/map_definition.h
  33. 8 0
      game/map/map_loader.cpp
  34. 77 26
      game/map/map_transformer.cpp
  35. 5 0
      game/map/map_transformer.h
  36. 37 0
      game/systems/arrow_system.cpp
  37. 29 0
      game/systems/arrow_system.h
  38. 67 10
      game/systems/combat_system.cpp
  39. 3 1
      game/systems/combat_system.h
  40. 29 34
      game/systems/movement_system.cpp
  41. 5 3
      game/systems/movement_system.h
  42. 2 2
      game/systems/selection_system.cpp
  43. 5 1
      game/systems/selection_system.h
  44. 52 0
      game/units/archer.cpp
  45. 17 0
      game/units/archer.h
  46. 12 0
      game/units/factory.cpp
  47. 28 0
      game/units/factory.h
  48. 61 0
      game/units/unit.cpp
  49. 55 0
      game/units/unit.h
  50. 14 0
      game/visuals/team_colors.h
  51. 1 1
      game/visuals/visual_catalog.cpp
  52. 1 0
      render/CMakeLists.txt
  53. 22 14
      render/entity/archer_renderer.cpp
  54. 2 2
      render/entity/registry.cpp
  55. 70 0
      render/geom/arrow.cpp
  56. 10 0
      render/geom/arrow.h
  57. 127 17
      render/gl/camera.cpp
  58. 31 0
      render/gl/camera.h
  59. 13 91
      render/gl/renderer.cpp
  60. 3 0
      render/gl/resources.h
  61. 231 77
      scripts/setup-deps.sh
  62. 44 99
      ui/qml/GameView.qml
  63. 23 8
      ui/qml/HUD.qml
  64. 68 0
      ui/qml/Main.qml

+ 5 - 3
CMakeLists.txt

@@ -31,7 +31,7 @@ endif()
 
 include_directories(${CMAKE_CURRENT_SOURCE_DIR})
 
-add_subdirectory(engine)
+# engine core moved under game; no separate engine subdir target
 add_subdirectory(render)
 add_subdirectory(game)
 add_subdirectory(ui)
@@ -42,12 +42,14 @@ if(QT_VERSION_MAJOR EQUAL 6)
     qt6_add_executable(standard_of_iron
         main.cpp
         app/game_engine.cpp
+        app/selected_units_model.cpp
         ui/gl_view.cpp
     )
 else()
     add_executable(standard_of_iron
         main.cpp
         app/game_engine.cpp
+        app/selected_units_model.cpp
         ui/gl_view.cpp
     )
 endif()
@@ -64,8 +66,8 @@ if(QT_VERSION_MAJOR EQUAL 6)
         RESOURCES
             assets/shaders/basic.vert
             assets/shaders/basic.frag
+            assets/shaders/grid.frag
             assets/maps/test_map.json
-            assets/maps/test_map.txt
             assets/visuals/unit_visuals.json
         DEPENDENCIES
             Qt6::QuickControls2
@@ -83,8 +85,8 @@ else()
         FILES
             assets/shaders/basic.vert
             assets/shaders/basic.frag
+            assets/shaders/grid.frag
             assets/maps/test_map.json
-            assets/maps/test_map.txt
             assets/visuals/unit_visuals.json
     )
 endif()

+ 1 - 1
README.md

@@ -69,7 +69,7 @@ make -j$(nproc)
 ## Project Structure
 
 ```
-├── engine/core/          # ECS, events, serialization
+├── game/core/            # ECS, events, serialization
 ├── render/gl/            # OpenGL rendering system
 ├── game/systems/         # Game logic systems (AI, combat, movement)
 ├── assets/               # Game assets

+ 343 - 70
app/game_engine.cpp

@@ -3,24 +3,34 @@
 #include <QQuickWindow>
 #include <QOpenGLContext>
 #include <QDebug>
+#include <QVariant>
 
-#include "engine/core/world.h"
-#include "engine/core/component.h"
+#include "game/core/world.h"
+#include "game/core/component.h"
 #include "render/gl/renderer.h"
 #include "render/gl/camera.h"
 #include "render/gl/resources.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/map/map_loader.h"
 #include "game/map/map_transformer.h"
 #include "game/visuals/visual_catalog.h"
+#include "game/units/factory.h"
+#include "game/map/environment.h"
 
+#include "selected_units_model.h"
+#include <cmath>
 GameEngine::GameEngine() {
     m_world    = std::make_unique<Engine::Core::World>();
     m_renderer = std::make_unique<Render::GL::Renderer>();
     m_camera   = std::make_unique<Render::GL::Camera>();
 
+    std::unique_ptr<Engine::Core::System> arrowSys = std::make_unique<Game::Systems::ArrowSystem>();
+    m_arrowSystem = static_cast<Game::Systems::ArrowSystem*>(arrowSys.get());
+    m_world->addSystem(std::move(arrowSys));
+
     m_world->addSystem(std::make_unique<Game::Systems::MovementSystem>());
     m_world->addSystem(std::make_unique<Game::Systems::CombatSystem>());
     m_world->addSystem(std::make_unique<Game::Systems::AISystem>());
@@ -28,7 +38,15 @@ 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)
+        m_selectedUnitsModel = new SelectedUnitsModel(this, this);
+        QMetaObject::invokeMethod(m_selectedUnitsModel, "refresh");
 }
 
 GameEngine::~GameEngine() = default;
@@ -38,17 +56,135 @@ void GameEngine::onMapClicked(qreal sx, qreal sy) {
     ensureInitialized();
     QVector3D hit;
     if (!screenToGround(QPointF(sx, sy), hit)) return;
-    if (auto* entity = m_world->getEntity(m_playerUnitId)) {
-        if (auto* move = entity->getComponent<Engine::Core::MovementComponent>()) {
-            move->targetX = hit.x();
-            move->targetY = hit.z();
-            move->hasTarget = true;
-            // Set selected to true to visualize with ring
-            if (auto* unit = entity->getComponent<Engine::Core::UnitComponent>()) {
-                unit->selected = true;
-            }
+    // Default behavior: treat left click as selection click (single)
+    onClickSelect(sx, sy, false);
+}
+
+void GameEngine::onRightClick(qreal sx, qreal sy) {
+    if (!m_window) return;
+    ensureInitialized();
+    QVector3D hit;
+    if (!screenToGround(QPointF(sx, sy), hit)) return;
+    qInfo() << "Right-click at screen" << QPointF(sx, sy) << "-> world" << hit;
+    // Issue move command to all selected units
+    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;
+    }
+}
+
+void GameEngine::onClickSelect(qreal sx, qreal sy, bool additive) {
+    if (!m_window || !m_selectionSystem) return;
+    ensureInitialized();
+    // Pick closest unit to the cursor in screen space within a radius
+    const float pickRadius = 18.0f; // pixels
+    float bestDist2 = pickRadius * pickRadius;
+    Engine::Core::EntityID bestId = 0;
+    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 (d2 < bestDist2) { bestDist2 = d2; bestId = e->getId(); }
+    }
+    if (bestId) {
+        // If we clicked near a unit, this is a selection click. Optionally clear previous selection.
+        if (!additive) m_selectionSystem->clearSelection();
+        // Clicked near a unit: (re)select it
+        m_selectionSystem->selectUnit(bestId);
+        syncSelectionFlags();
+        emit selectedUnitsChanged();
+        if (m_selectedUnitsModel) QMetaObject::invokeMethod(m_selectedUnitsModel, "refresh");
+        return;
+    }
+
+    // No unit under cursor. If we have a current selection, interpret this as a move command
+    const auto& selected = m_selectionSystem->getSelectedUnits();
+    if (!selected.empty()) {
+        QVector3D hit;
+        if (!screenToGround(QPointF(sx, sy), hit)) {
+            // 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;
+        }
+        // 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;
+        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());
+        }
+    }
+    syncSelectionFlags();
+    emit selectedUnitsChanged();
+    if (m_selectedUnitsModel) QMetaObject::invokeMethod(m_selectedUnitsModel, "refresh");
 }
 
 void GameEngine::initialize() {
@@ -69,25 +205,20 @@ void GameEngine::initialize() {
     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 mapPath = QString::fromUtf8("assets/maps/test_map.json");
     QString err;
     if (Game::Map::MapLoader::loadFromJsonFile(mapPath, def, &err)) {
         m_loadedMapName = def.name;
-    // Configure camera
-    m_camera->setRTSView(def.camera.center, def.camera.distance, def.camera.tiltDeg);
-    // Cache perspective params and set initial perspective (aspect will be set in render)
-    m_camFov = def.camera.fovY; m_camNear = def.camera.nearPlane; m_camFar = def.camera.farPlane;
-    m_camera->setPerspective(m_camFov, 16.0f/9.0f, m_camNear, m_camFar);
-
-        // Configure grid on renderer
-        Render::GL::Renderer::GridParams gp;
-        gp.cellSize = def.grid.tileSize;
-        gp.extent = std::max(def.grid.width, def.grid.height) * def.grid.tileSize * 0.5f; // half-size plane scale
-        m_renderer->setGridParams(gp);
-
-        // Populate world
+        // 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();
@@ -96,9 +227,8 @@ void GameEngine::initialize() {
         }
     } else {
         qWarning() << "Map load failed:" << err << "- using fallback unit";
-    m_camera->setRTSView(QVector3D(0, 0, 0), 15.0f, 45.0f);
-    m_camFov = 45.0f; m_camNear = 0.1f; m_camFar = 1000.0f;
-    m_camera->setPerspective(m_camFov, 16.0f/9.0f, m_camNear, m_camFar);
+        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;
@@ -108,11 +238,39 @@ void GameEngine::ensureInitialized() { if (!m_initialized) initialize(); }
 
 void GameEngine::update(float dt) {
     if (m_world) m_world->update(dt);
+    // 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);
+            }
+        }
+    }
+
+    // Keep SelectedUnitsModel in sync with health changes even if selection IDs haven't changed
+    if (m_selectedUnitsModel) QMetaObject::invokeMethod(m_selectedUnitsModel, "refresh", Qt::QueuedConnection);
 }
 
 void GameEngine::render(int pixelWidth, int pixelHeight) {
     if (!m_renderer || !m_world || !m_initialized) return;
     if (pixelWidth > 0 && pixelHeight > 0) {
+        m_viewW = pixelWidth; m_viewH = pixelHeight;
         m_renderer->setViewport(pixelWidth, pixelHeight);
            float aspect = float(pixelWidth) / float(pixelHeight);
            // Keep current camera fov/planes from map but update aspect
@@ -120,57 +278,172 @@ void GameEngine::render(int pixelWidth, int pixelHeight) {
     }
     m_renderer->beginFrame();
     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);
+        }
+    }
     m_renderer->endFrame();
 }
 
 void GameEngine::setupFallbackTestUnit() {
-    auto entity = m_world->createEntity();
-    m_playerUnitId = entity->getId();
+    // 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";
+}
 
-    auto transform = entity->addComponent<Engine::Core::TransformComponent>();
-    transform->position = {0.0f, 0.0f, 0.0f};
-    transform->scale    = {0.5f, 0.5f, 0.5f};
-    // Keep upright; camera provides the tilt
+bool GameEngine::screenToGround(const QPointF& screenPt, QVector3D& outWorld) {
+    if (!m_window || !m_camera) return false;
+    return m_camera->screenToGround(float(screenPt.x()), float(screenPt.y()),
+                                    float(m_window->width()), float(m_window->height()), outWorld);
+}
 
-    auto renderable = entity->addComponent<Engine::Core::RenderableComponent>("", "");
-    renderable->visible = true;
-    renderable->mesh = Engine::Core::RenderableComponent::MeshKind::Capsule;
-    renderable->color[0] = 0.8f; renderable->color[1] = 0.9f; renderable->color[2] = 1.0f;
+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);
+}
 
-    auto unit = entity->addComponent<Engine::Core::UnitComponent>();
-    unit->unitType = "archer";
-    unit->health = 80;
-    unit->maxHealth = 80;
-    unit->speed = 3.0f;
+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;
+    }
+}
 
-    entity->addComponent<Engine::Core::MovementComponent>();
+void GameEngine::syncSelectionFlags() {
+    if (!m_world || !m_selectionSystem) return;
+    clearAllSelections();
+    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 any dead units were filtered out, rebuild selection
+    if (toKeep.size() != sel.size()) {
+        m_selectionSystem->clearSelection();
+        for (auto id : toKeep) m_selectionSystem->selectUnit(id);
+    }
 }
 
-bool GameEngine::screenToGround(const QPointF& screenPt, QVector3D& outWorld) {
-    if (!m_window || !m_camera) return false;
-    float w = float(m_window->width());
-    float h = float(m_window->height());
-    if (w <= 0 || h <= 0) return false;
-
-    float x = (2.0f * float(screenPt.x()) / w) - 1.0f;
-    float y = 1.0f - (2.0f * float(screenPt.y()) / h);
-
-    bool ok = false;
-    QMatrix4x4 invVP = (m_camera->getProjectionMatrix() * m_camera->getViewMatrix()).inverted(&ok);
-    if (!ok) return false;
-
-    QVector4D nearClip(x, y, 0.0f, 1.0f);
-    QVector4D farClip (x, y, 1.0f, 1.0f);
-    QVector4D nearWorld4 = invVP * nearClip;
-    QVector4D farWorld4  = invVP * farClip;
-    if (nearWorld4.w() == 0.0f || farWorld4.w() == 0.0f) return false;
-    QVector3D rayOrigin = (nearWorld4 / nearWorld4.w()).toVector3D();
-    QVector3D rayEnd    = (farWorld4  / farWorld4.w()).toVector3D();
-    QVector3D rayDir    = (rayEnd - rayOrigin).normalized();
-
-    if (qFuzzyIsNull(rayDir.y())) return false;
-    float t = -rayOrigin.y() / rayDir.y();
-    if (t < 0.0f) return false;
-    outWorld = rayOrigin + rayDir * t;
-    return true;
+// --- Camera control API (invokable from QML) ---
+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();
+}
+
+void GameEngine::cameraElevate(float dy) {
+    ensureInitialized();
+    if (!m_camera) return;
+    // Elevate via camera API
+    m_camera->elevate(dy);
+    if (m_followSelectionEnabled) m_camera->captureFollowOffset();
 }
+
+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();
+}
+
+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();
+}
+
+void GameEngine::cameraFollowSelection(bool enable) {
+    ensureInitialized();
+    m_followSelectionEnabled = enable;
+    if (m_camera) m_camera->setFollowEnabled(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();
+            }
+        }
+    } 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
+        // (updateVectors is called inside camera lookAt/setTarget, so here we normalize front)
+        auto pos = m_camera->getPosition();
+        auto tgt = m_camera->getTarget();
+        QVector3D front = (tgt - pos).normalized();
+        m_camera->lookAt(pos, tgt, QVector3D(0,1,0));
+    }
+}
+
+void GameEngine::cameraSetFollowLerp(float alpha) {
+    ensureInitialized();
+    if (!m_camera) return;
+    float a = std::clamp(alpha, 0.0f, 1.0f);
+    m_camera->setFollowLerp(a);
+}
+
+QObject* GameEngine::selectedUnitsModel() { return m_selectedUnitsModel; }

+ 30 - 1
app/game_engine.h

@@ -20,7 +20,7 @@ class Camera;
 class ResourceManager;
 } }
 
-namespace Game { namespace Systems { class SelectionSystem; } }
+namespace Game { namespace Systems { class SelectionSystem; class ArrowSystem; } }
 
 class QQuickWindow;
 
@@ -30,7 +30,20 @@ public:
     GameEngine();
     ~GameEngine();
 
+    Q_PROPERTY(QObject* selectedUnitsModel READ selectedUnitsModel NOTIFY selectedUnitsChanged)
+
     Q_INVOKABLE void onMapClicked(qreal sx, qreal sy);
+    Q_INVOKABLE void onRightClick(qreal sx, qreal sy);
+    Q_INVOKABLE void onClickSelect(qreal sx, qreal sy, bool additive = false);
+    Q_INVOKABLE void onAreaSelected(qreal x1, qreal y1, qreal x2, qreal y2, bool additive = false);
+
+    // Camera controls exposed to QML
+    Q_INVOKABLE void cameraMove(float dx, float dz);      // move along ground plane (right/forward XZ)
+    Q_INVOKABLE void cameraElevate(float dy);             // move up/down in Y
+    Q_INVOKABLE void cameraYaw(float degrees);            // rotate around current target (yaw)
+    Q_INVOKABLE void cameraOrbit(float yawDeg, float pitchDeg); // orbit around target by yaw/pitch
+    Q_INVOKABLE void cameraFollowSelection(bool enable);  // follow the currently selected troops
+    Q_INVOKABLE void cameraSetFollowLerp(float alpha);    // 0..1, 1 = snap to center
 
     void setWindow(QQuickWindow* w) { m_window = w; }
 
@@ -40,9 +53,14 @@ public:
     void render(int pixelWidth, int pixelHeight);
 
 private:
+    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();
 
     std::unique_ptr<Engine::Core::World> m_world;
     std::unique_ptr<Render::GL::Renderer> m_renderer;
@@ -52,6 +70,12 @@ private:
     QQuickWindow* m_window = nullptr;
     Engine::Core::EntityID m_playerUnitId = 0;
     bool m_initialized = false;
+    int m_viewW = 0;
+    int m_viewH = 0;
+    // 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)
@@ -61,4 +85,9 @@ private:
     float m_camFov = 45.0f;
     float m_camNear = 0.1f;
     float m_camFar = 1000.0f;
+    QObject* m_selectedUnitsModel = nullptr;
+    int m_localOwnerId = 1; // local player's owner/team id
+signals:
+    void selectedUnitsChanged();
+
 };

+ 91 - 0
app/selected_units_model.cpp

@@ -0,0 +1,91 @@
+#include "selected_units_model.h"
+#include "game_engine.h"
+#include "../game/systems/selection_system.h"
+#include "../game/core/world.h"
+#include "../game/core/component.h"
+#include <algorithm>
+
+SelectedUnitsModel::SelectedUnitsModel(GameEngine* engine, QObject* parent)
+    : QAbstractListModel(parent), m_engine(engine) {
+}
+
+int SelectedUnitsModel::rowCount(const QModelIndex& parent) const {
+    if (parent.isValid()) return 0;
+    return static_cast<int>(m_ids.size());
+}
+
+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 {};
+    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;
+    }
+    return {};
+}
+
+QHash<int, QByteArray> SelectedUnitsModel::roleNames() const {
+    return {
+        { UnitIdRole, "unitId" },
+        { NameRole, "name" },
+        { HealthRole, "health" },
+        { MaxHealthRole, "maxHealth" },
+        { HealthRatioRole, "healthRatio" }
+    };
+}
+
+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();
+
+    // 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())) {
+        if (!m_ids.empty()) {
+            QModelIndex first = index(0, 0);
+            QModelIndex last = index(static_cast<int>(m_ids.size()) - 1, 0);
+            emit dataChanged(first, last, { HealthRole, MaxHealthRole, HealthRatioRole });
+        }
+        return;
+    }
+
+    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)) {
+                if (auto* u = e->getComponent<Engine::Core::UnitComponent>()) {
+                    if (u->health > 0) m_ids.push_back(id);
+                }
+            }
+        }
+    }
+    endResetModel();
+}

+ 26 - 0
app/selected_units_model.h

@@ -0,0 +1,26 @@
+#pragma once
+
+#include <QAbstractListModel>
+#include <vector>
+#include "../game/core/entity.h"
+
+class GameEngine;
+
+class SelectedUnitsModel : public QAbstractListModel {
+    Q_OBJECT
+public:
+    enum Roles { UnitIdRole = Qt::UserRole + 1, NameRole, HealthRole, MaxHealthRole, HealthRatioRole };
+
+    explicit SelectedUnitsModel(GameEngine* engine, QObject* parent = nullptr);
+
+    int rowCount(const QModelIndex& parent = QModelIndex()) const override;
+    QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
+    QHash<int, QByteArray> roleNames() const override;
+
+public slots:
+    void refresh();
+
+private:
+    GameEngine* m_engine = nullptr;
+    std::vector<Engine::Core::EntityID> m_ids;
+};

+ 11 - 1
assets/maps/test_map.json

@@ -1,5 +1,6 @@
 {
   "name": "Test Map (JSON)",
+  "coordSystem": "grid",
   "grid": {
     "width": 100,
     "height": 100,
@@ -14,6 +15,15 @@
     "far": 1000.0
   },
   "spawns": [
-    { "type": "archer", "x": 0.0, "z": 0.0, "playerId": 1 }
+    { "type": "archer", "x": 50, "z": 50, "playerId": 1 },
+    { "type": "archer", "x": 52, "z": 50, "playerId": 1 },
+    { "type": "archer", "x": 50, "z": 52, "playerId": 1 },
+    { "type": "archer", "x": 48, "z": 52, "playerId": 1 },
+    { "type": "archer", "x": 52, "z": 52, "playerId": 1 },
+    
+    { "type": "archer", "x": 56, "z": 50, "playerId": 2 },
+    { "type": "archer", "x": 58, "z": 50, "playerId": 2 },
+    { "type": "archer", "x": 56, "z": 52, "playerId": 2 },
+    { "type": "archer", "x": 58, "z": 52, "playerId": 2 }
   ]
 }

+ 0 - 41
assets/maps/test_map.txt

@@ -1,41 +0,0 @@
-# Sample map file for Standard of Iron RTS
-# This is a basic text-based map format
-# In a full implementation, this would be binary or use a format like JSON/XML
-
-MAP_VERSION 1.0
-MAP_NAME "Test Map"
-MAP_SIZE 64 64
-TILE_SIZE 1.0
-
-# Terrain data (simplified)
-# 0 = grass, 1 = water, 2 = mountain, 3 = forest
-TERRAIN_START
-0000000000000000000000000000000000000000000000000000000000000000
-0000000000000000000000000000000000000000000000000000000000000000
-0000000000000000000111111111111111110000000000000000000000000000
-0000000000000000001111111111111111111000000000000000000000000000
-0000000000000000011111111111111111111100000000000000000000000000
-0000000000000000111111111111111111111110000000000000000000000000
-0000000000000001111111111111111111111111000000000000000000000000
-0000000000000011111111111111111111111111100000000000000000000000
-0000000000000111111111111111111111111111110000000000000000000000
-0000000000001111111111111111111111111111111000000000000000000000
-TERRAIN_END
-
-# Starting positions
-PLAYER_START 1 5 5
-PLAYER_START 2 58 58
-
-# Resource deposits
-RESOURCE gold 10 10 1000
-RESOURCE wood 15 15 500
-RESOURCE gold 50 50 1200
-RESOURCE wood 45 55 800
-
-# Initial units (player_id unit_type x y)
-UNIT 1 warrior 8 8
-UNIT 1 archer 9 8
-UNIT 1 worker 6 6
-UNIT 2 warrior 55 55
-UNIT 2 archer 56 55
-UNIT 2 worker 57 57

+ 17 - 29
assets/shaders/basic.frag

@@ -1,35 +1,23 @@
 #version 330 core
 
-in vec3 FragPos;
-in vec3 Normal;
-in vec2 TexCoord;
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
 
-out vec4 FragColor;
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
 
-uniform sampler2D texture1;
-uniform vec3 lightPos;
-uniform vec3 lightColor;
-uniform vec3 viewPos;
+out vec4 FragColor;
 
-void main()
-{
-    // Ambient lighting
-    float ambientStrength = 0.1;
-    vec3 ambient = ambientStrength * lightColor;
-    
-    // Diffuse lighting
-    vec3 norm = normalize(Normal);
-    vec3 lightDir = normalize(lightPos - FragPos);
-    float diff = max(dot(norm, lightDir), 0.0);
-    vec3 diffuse = diff * lightColor;
-    
-    // Specular lighting
-    float specularStrength = 0.5;
-    vec3 viewDir = normalize(viewPos - FragPos);
-    vec3 reflectDir = reflect(-lightDir, norm);
-    float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32);
-    vec3 specular = specularStrength * spec * lightColor;
-    
-    vec3 result = (ambient + diffuse + specular) * texture(texture1, TexCoord).rgb;
-    FragColor = vec4(result, 1.0);
+void main() {
+    vec3 color = u_color;
+    if (u_useTexture) {
+        color *= texture(u_texture, v_texCoord).rgb;
+    }
+    vec3 normal = normalize(v_normal);
+    vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
+    float diff = max(dot(normal, lightDir), 0.2);
+    color *= diff;
+    FragColor = vec4(color, 1.0);
 }

+ 14 - 16
assets/shaders/basic.vert

@@ -1,22 +1,20 @@
 #version 330 core
 
-layout (location = 0) in vec3 aPosition;
-layout (location = 1) in vec3 aNormal;
-layout (location = 2) in vec2 aTexCoord;
+layout (location = 0) in vec3 a_position;
+layout (location = 1) in vec3 a_normal;
+layout (location = 2) in vec2 a_texCoord;
 
-uniform mat4 uModel;
-uniform mat4 uView;
-uniform mat4 uProjection;
+uniform mat4 u_model;
+uniform mat4 u_view;
+uniform mat4 u_projection;
 
-out vec3 FragPos;
-out vec3 Normal;
-out vec2 TexCoord;
+out vec3 v_normal;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
 
-void main()
-{
-    FragPos = vec3(uModel * vec4(aPosition, 1.0));
-    Normal = mat3(transpose(inverse(uModel))) * aNormal;
-    TexCoord = aTexCoord;
-    
-    gl_Position = uProjection * uView * vec4(FragPos, 1.0);
+void main() {
+    v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+    v_texCoord = a_texCoord;
+    v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+    gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);
 }

+ 22 - 0
assets/shaders/grid.frag

@@ -0,0 +1,22 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform vec3 u_gridColor;
+uniform vec3 u_lineColor;
+uniform float u_cellSize;
+uniform float u_thickness; // fraction of cell (0..0.5)
+
+out vec4 FragColor;
+
+void main() {
+    vec2 coord = v_worldPos.xz / u_cellSize;
+    vec2 g = abs(fract(coord) - 0.5);
+    float lineX = step(0.5 - u_thickness, g.x);
+    float lineY = step(0.5 - u_thickness, g.y);
+    float lineMask = max(lineX, lineY);
+    vec3 col = mix(u_gridColor, u_lineColor, lineMask);
+    FragColor = vec4(col, 1.0);
+}

+ 0 - 11
engine/CMakeLists.txt

@@ -1,11 +0,0 @@
-add_library(engine_core STATIC
-    core/entity.cpp
-    core/component.cpp
-    core/system.cpp
-    core/world.cpp
-    core/event_manager.cpp
-    core/serialization.cpp
-)
-
-target_include_directories(engine_core PUBLIC .)
-target_link_libraries(engine_core PUBLIC Qt6::Core)

+ 0 - 7
engine/core/entity.cpp

@@ -1,7 +0,0 @@
-#include "entity.h"
-
-namespace Engine::Core {
-
-// Entity implementation is mostly in header due to templates
-
-} // namespace Engine::Core

+ 0 - 82
engine/core/entity.h

@@ -1,82 +0,0 @@
-#pragma once
-
-#include <cstdint>
-#include <vector>
-#include <memory>
-#include <typeindex>
-#include <unordered_map>
-
-namespace Engine::Core {
-
-using EntityID = std::uint32_t;
-constexpr EntityID NULL_ENTITY = 0;
-
-class Component {
-public:
-    virtual ~Component() = default;
-};
-
-class Entity {
-public:
-    Entity(EntityID id) : m_id(id) {}
-    
-    EntityID getId() const { return m_id; }
-    
-    template<typename T, typename... Args>
-    T* addComponent(Args&&... args);
-    
-    template<typename T>
-    T* getComponent();
-    
-    template<typename T>
-    const T* getComponent() const;
-    
-    template<typename T>
-    void removeComponent();
-    
-    template<typename T>
-    bool hasComponent() const;
-
-private:
-    EntityID m_id;
-    std::unordered_map<std::type_index, std::unique_ptr<Component>> m_components;
-};
-
-template<typename T, typename... Args>
-T* Entity::addComponent(Args&&... args) {
-    static_assert(std::is_base_of_v<Component, T>, "T must inherit from Component");
-    auto component = std::make_unique<T>(std::forward<Args>(args)...);
-    auto ptr = component.get();
-    m_components[std::type_index(typeid(T))] = std::move(component);
-    return ptr;
-}
-
-template<typename T>
-T* Entity::getComponent() {
-    auto it = m_components.find(std::type_index(typeid(T)));
-    if (it != m_components.end()) {
-        return static_cast<T*>(it->second.get());
-    }
-    return nullptr;
-}
-
-template<typename T>
-const T* Entity::getComponent() const {
-    auto it = m_components.find(std::type_index(typeid(T)));
-    if (it != m_components.end()) {
-        return static_cast<const T*>(it->second.get());
-    }
-    return nullptr;
-}
-
-template<typename T>
-void Entity::removeComponent() {
-    m_components.erase(std::type_index(typeid(T)));
-}
-
-template<typename T>
-bool Entity::hasComponent() const {
-    return m_components.find(std::type_index(typeid(T))) != m_components.end();
-}
-
-} // namespace Engine::Core

+ 0 - 7
engine/core/event_manager.cpp

@@ -1,7 +0,0 @@
-#include "event_manager.h"
-
-namespace Engine::Core {
-
-// EventManager implementation is mostly in header due to templates
-
-} // namespace Engine::Core

+ 0 - 7
engine/core/system.cpp

@@ -1,7 +0,0 @@
-#include "system.h"
-
-namespace Engine::Core {
-
-// System base implementation
-
-} // namespace Engine::Core

+ 19 - 1
game/CMakeLists.txt

@@ -1,13 +1,31 @@
+# Core ECS library (moved from engine/core to game/core)
+add_library(engine_core STATIC
+    core/entity.cpp
+    core/component.cpp
+    core/system.cpp
+    core/world.cpp
+    core/event_manager.cpp
+    core/serialization.cpp
+)
+
+target_include_directories(engine_core PUBLIC .)
+target_link_libraries(engine_core PUBLIC Qt6::Core)
+
 add_library(game_systems STATIC
     systems/movement_system.cpp
     systems/combat_system.cpp
     systems/ai_system.cpp
     systems/pathfinding.cpp
     systems/selection_system.cpp
+    systems/arrow_system.cpp
     map/map_loader.cpp
     map/map_transformer.cpp
+    map/environment.cpp
     visuals/visual_catalog.cpp
+    units/unit.cpp
+    units/archer.cpp
+    units/factory.cpp
 )
 
 target_include_directories(game_systems PUBLIC .)
-target_link_libraries(game_systems PUBLIC Qt6::Core Qt6::Gui engine_core)
+target_link_libraries(game_systems PUBLIC Qt6::Core Qt6::Gui engine_core render_gl)

+ 1 - 1
engine/core/component.cpp → game/core/component.cpp

@@ -4,4 +4,4 @@ namespace Engine::Core {
 
 // Component implementations are mostly in header due to simplicity
 
-} // namespace Engine::Core
+} // namespace Engine::Core

+ 19 - 8
engine/core/component.h → game/core/component.h

@@ -1,10 +1,13 @@
 #pragma once
 
 #include "entity.h"
+#include <string>
+#include <vector>
+
 
 namespace Engine::Core {
 
-// Transform Component
+
 class TransformComponent : public Component {
 public:
     TransformComponent(float x = 0.0f, float y = 0.0f, float z = 0.0f, 
@@ -18,7 +21,6 @@ public:
     Vec3 scale;
 };
 
-// Renderable Component
 class RenderableComponent : public Component {
 public:
     enum class MeshKind { None, Quad, Plane, Cube, Capsule, Ring };
@@ -32,32 +34,41 @@ public:
     std::string texturePath;
     bool visible;
     MeshKind mesh;
-    float color[3]; // RGB 0..1
+    float color[3];
 };
 
-// Unit Component (for RTS units)
 class UnitComponent : public Component {
 public:
     UnitComponent(int health = 100, int maxHealth = 100, float speed = 1.0f)
-        : health(health), maxHealth(maxHealth), speed(speed), selected(false) {}
+        : health(health), maxHealth(maxHealth), speed(speed), selected(false), ownerId(0) {}
 
     int health;
     int maxHealth;
     float speed;
     bool selected;
     std::string unitType;
+    int ownerId; // faction/player ownership
 };
 
-// Movement Component
 class MovementComponent : public Component {
 public:
     MovementComponent() : hasTarget(false), targetX(0.0f), targetY(0.0f), vx(0.0f), vz(0.0f) {}
 
     bool hasTarget;
     float targetX, targetY;
-    // velocity in XZ plane for smoothing
     float vx, vz;
     std::vector<std::pair<float, float>> path;
 };
 
-} // namespace Engine::Core
+class AttackComponent : public Component {
+public:
+    AttackComponent(float range = 2.0f, int damage = 10, float cooldown = 1.0f)
+        : range(range), damage(damage), cooldown(cooldown), timeSinceLast(0.0f) {}
+
+    float range;
+    int damage;
+    float cooldown;
+    float timeSinceLast;
+};
+
+} // namespace Engine::Core

+ 13 - 0
game/core/entity.cpp

@@ -0,0 +1,13 @@
+#include "entity.h"
+#include <typeindex>
+
+namespace Engine::Core {
+
+Entity::Entity(EntityID id) : m_id(id) {}
+
+EntityID Entity::getId() const { return m_id; }
+
+// Template methods remain header-only in previous layout.
+// For simplicity, we keep non-template parts here.
+
+} // namespace Engine::Core

+ 68 - 0
game/core/entity.h

@@ -0,0 +1,68 @@
+#pragma once
+
+#include <cstdint>
+#include <vector>
+#include <memory>
+#include <typeindex>
+#include <unordered_map>
+#include <type_traits>
+
+namespace Engine::Core {
+
+using EntityID = std::uint32_t;
+constexpr EntityID NULL_ENTITY = 0;
+
+class Component {
+public:
+    virtual ~Component() = default;
+};
+
+class Entity {
+public:
+    Entity(EntityID id);
+    
+    EntityID getId() const;
+    
+    template<typename T, typename... Args>
+    T* addComponent(Args&&... args) {
+        static_assert(std::is_base_of_v<Component, T>, "T must inherit from Component");
+        auto component = std::make_unique<T>(std::forward<Args>(args)...);
+        auto ptr = component.get();
+        m_components[std::type_index(typeid(T))] = std::move(component);
+        return ptr;
+    }
+    
+    template<typename T>
+    T* getComponent() {
+        auto it = m_components.find(std::type_index(typeid(T)));
+        if (it != m_components.end()) {
+            return static_cast<T*>(it->second.get());
+        }
+        return nullptr;
+    }
+    
+    template<typename T>
+    const T* getComponent() const {
+        auto it = m_components.find(std::type_index(typeid(T)));
+        if (it != m_components.end()) {
+            return static_cast<const T*>(it->second.get());
+        }
+        return nullptr;
+    }
+    
+    template<typename T>
+    void removeComponent() {
+        m_components.erase(std::type_index(typeid(T)));
+    }
+    
+    template<typename T>
+    bool hasComponent() const {
+        return m_components.find(std::type_index(typeid(T))) != m_components.end();
+    }
+
+private:
+    EntityID m_id;
+    std::unordered_map<std::type_index, std::unique_ptr<Component>> m_components;
+};
+
+} // namespace Engine::Core

+ 7 - 0
game/core/event_manager.cpp

@@ -0,0 +1,7 @@
+#include "event_manager.h"
+
+namespace Engine::Core {
+
+// Templates in header; nothing here.
+
+} // namespace Engine::Core

+ 17 - 26
engine/core/event_manager.h → game/core/event_manager.h

@@ -20,38 +20,29 @@ using EventHandler = std::function<void(const T&)>;
 class EventManager {
 public:
     template<typename T>
-    void subscribe(EventHandler<T> handler);
+    void subscribe(EventHandler<T> handler) {
+        static_assert(std::is_base_of_v<Event, T>, "T must inherit from Event");
+        auto wrapper = [handler](const void* event) {
+            handler(*static_cast<const T*>(event));
+        };
+        m_handlers[std::type_index(typeid(T))].push_back(wrapper);
+    }
     
     template<typename T>
-    void publish(const T& event);
+    void publish(const T& event) {
+        static_assert(std::is_base_of_v<Event, T>, "T must inherit from Event");
+        auto it = m_handlers.find(std::type_index(typeid(T)));
+        if (it != m_handlers.end()) {
+            for (const auto& handler : it->second) {
+                handler(&event);
+            }
+        }
+    }
     
 private:
     std::unordered_map<std::type_index, std::vector<std::function<void(const void*)>>> m_handlers;
 };
 
-template<typename T>
-void EventManager::subscribe(EventHandler<T> handler) {
-    static_assert(std::is_base_of_v<Event, T>, "T must inherit from Event");
-    
-    auto wrapper = [handler](const void* event) {
-        handler(*static_cast<const T*>(event));
-    };
-    
-    m_handlers[std::type_index(typeid(T))].push_back(wrapper);
-}
-
-template<typename T>
-void EventManager::publish(const T& event) {
-    static_assert(std::is_base_of_v<Event, T>, "T must inherit from Event");
-    
-    auto it = m_handlers.find(std::type_index(typeid(T)));
-    if (it != m_handlers.end()) {
-        for (const auto& handler : it->second) {
-            handler(&event);
-        }
-    }
-}
-
 // Common game events
 class UnitSelectedEvent : public Event {
 public:
@@ -66,4 +57,4 @@ public:
     float x, y;
 };
 
-} // namespace Engine::Core
+} // namespace Engine::Core

+ 7 - 14
engine/core/serialization.cpp → game/core/serialization.cpp

@@ -13,8 +13,7 @@ QJsonObject Serialization::serializeEntity(const Entity* entity) {
     QJsonObject entityObj;
     entityObj["id"] = static_cast<qint64>(entity->getId());
     
-    // Serialize components (simplified for basic components)
-    if (auto transform = entity->getComponent<TransformComponent>()) {
+    if (auto transform = const_cast<Entity*>(entity)->getComponent<TransformComponent>()) {
         QJsonObject transformObj;
         transformObj["posX"] = transform->position.x;
         transformObj["posY"] = transform->position.y;
@@ -28,13 +27,14 @@ QJsonObject Serialization::serializeEntity(const Entity* entity) {
         entityObj["transform"] = transformObj;
     }
     
-    if (auto unit = entity->getComponent<UnitComponent>()) {
+    if (auto unit = const_cast<Entity*>(entity)->getComponent<UnitComponent>()) {
         QJsonObject unitObj;
         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;
     }
     
@@ -42,7 +42,6 @@ QJsonObject Serialization::serializeEntity(const Entity* entity) {
 }
 
 void Serialization::deserializeEntity(Entity* entity, const QJsonObject& json) {
-    // Deserialize components
     if (json.contains("transform")) {
         auto transformObj = json["transform"].toObject();
         auto transform = entity->addComponent<TransformComponent>();
@@ -63,18 +62,15 @@ 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->selected = unitObj["selected"].toBool();
+    unit->unitType = unitObj["unitType"].toString().toStdString();
+    unit->ownerId = unitObj["ownerId"].toInt(0);
     }
 }
 
 QJsonDocument Serialization::serializeWorld(const World* world) {
     QJsonObject worldObj;
     QJsonArray entitiesArray;
-    
-    // This is a simplified implementation
-    // In a real scenario, we'd need access to world's entities
-    
     worldObj["entities"] = entitiesArray;
     return QJsonDocument(worldObj);
 }
@@ -82,7 +78,6 @@ QJsonDocument Serialization::serializeWorld(const World* world) {
 void Serialization::deserializeWorld(World* world, const QJsonDocument& doc) {
     auto worldObj = doc.object();
     auto entitiesArray = worldObj["entities"].toArray();
-    
     for (const auto& value : entitiesArray) {
         auto entityObj = value.toObject();
         auto entity = world->createEntity();
@@ -96,7 +91,6 @@ bool Serialization::saveToFile(const QString& filename, const QJsonDocument& doc
         qWarning() << "Could not open file for writing:" << filename;
         return false;
     }
-    
     file.write(doc.toJson());
     return true;
 }
@@ -107,9 +101,8 @@ QJsonDocument Serialization::loadFromFile(const QString& filename) {
         qWarning() << "Could not open file for reading:" << filename;
         return QJsonDocument();
     }
-    
     QByteArray data = file.readAll();
     return QJsonDocument::fromJson(data);
 }
 
-} // namespace Engine::Core
+} // namespace Engine::Core

+ 1 - 1
engine/core/serialization.h → game/core/serialization.h

@@ -18,4 +18,4 @@ public:
     static QJsonDocument loadFromFile(const QString& filename);
 };
 
-} // namespace Engine::Core
+} // namespace Engine::Core

+ 7 - 0
game/core/system.cpp

@@ -0,0 +1,7 @@
+#include "system.h"
+
+namespace Engine::Core {
+
+// Base system, no concrete logic.
+
+} // namespace Engine::Core

+ 1 - 3
engine/core/system.h → game/core/system.h

@@ -1,7 +1,5 @@
 #pragma once
 
-#include "entity.h"
-#include <vector>
 #include <memory>
 
 namespace Engine::Core {
@@ -14,4 +12,4 @@ public:
     virtual void update(World* world, float deltaTime) = 0;
 };
 
-} // namespace Engine::Core
+} // namespace Engine::Core

+ 1 - 1
engine/core/world.cpp → game/core/world.cpp

@@ -32,4 +32,4 @@ void World::update(float deltaTime) {
     }
 }
 
-} // namespace Engine::Core
+} // namespace Engine::Core

+ 13 - 13
engine/core/world.h → game/core/world.h

@@ -19,9 +19,20 @@ public:
     
     void addSystem(std::unique_ptr<System> system);
     void update(float deltaTime);
+
+    // Expose systems for iteration (needed for cross-system events)
+    std::vector<std::unique_ptr<System>>& systems() { return m_systems; }
     
     template<typename T>
-    std::vector<Entity*> getEntitiesWith();
+    std::vector<Entity*> getEntitiesWith() {
+        std::vector<Entity*> result;
+        for (auto& [id, entity] : m_entities) {
+            if (entity->hasComponent<T>()) {
+                result.push_back(entity.get());
+            }
+        }
+        return result;
+    }
 
 private:
     EntityID m_nextEntityId = 1;
@@ -29,15 +40,4 @@ private:
     std::vector<std::unique_ptr<System>> m_systems;
 };
 
-template<typename T>
-std::vector<Entity*> World::getEntitiesWith() {
-    std::vector<Entity*> result;
-    for (auto& [id, entity] : m_entities) {
-        if (entity->hasComponent<T>()) {
-            result.push_back(entity.get());
-        }
-    }
-    return result;
-}
-
-} // namespace Engine::Core
+} // namespace Engine::Core

+ 26 - 0
game/map/environment.cpp

@@ -0,0 +1,26 @@
+#include "environment.h"
+#include "../../render/gl/renderer.h"
+#include "../../render/gl/camera.h"
+#include <algorithm>
+
+namespace Game { namespace Map {
+
+void Environment::apply(const MapDefinition& def, Render::GL::Renderer& renderer, Render::GL::Camera& camera) {
+    camera.setRTSView(def.camera.center, def.camera.distance, def.camera.tiltDeg);
+    camera.setPerspective(def.camera.fovY, 16.0f/9.0f, def.camera.nearPlane, def.camera.farPlane);
+    Render::GL::Renderer::GridParams gp;
+    gp.cellSize = def.grid.tileSize;
+    gp.extent = std::max(def.grid.width, def.grid.height) * def.grid.tileSize * 0.5f;
+    renderer.setGridParams(gp);
+}
+
+void Environment::applyDefault(Render::GL::Renderer& renderer, Render::GL::Camera& camera) {
+    camera.setRTSView(QVector3D(0,0,0), 15.0f, 45.0f);
+    camera.setPerspective(45.0f, 16.0f/9.0f, 0.1f, 1000.0f);
+    Render::GL::Renderer::GridParams gp;
+    gp.cellSize = 1.0f;
+    gp.extent = 50.0f;
+    renderer.setGridParams(gp);
+}
+
+} } // namespace Game::Map

+ 15 - 0
game/map/environment.h

@@ -0,0 +1,15 @@
+#pragma once
+
+#include "map_definition.h"
+
+namespace Render { namespace GL { class Renderer; class Camera; } }
+
+namespace Game { namespace Map {
+
+// Applies camera and ground/grid environment based on map definition
+struct Environment {
+    static void apply(const MapDefinition& def, Render::GL::Renderer& renderer, Render::GL::Camera& camera);
+    static void applyDefault(Render::GL::Renderer& renderer, Render::GL::Camera& camera);
+};
+
+} } // namespace Game::Map

+ 6 - 0
game/map/map_definition.h

@@ -28,11 +28,17 @@ struct UnitSpawn {
     int playerId = 0;
 };
 
+enum class CoordSystem {
+    Grid,   // x,z are grid indices [0..width-1], centered to world
+    World   // x,z are raw world coordinates
+};
+
 struct MapDefinition {
     QString name;
     GridDefinition grid;
     CameraDefinition camera;
     std::vector<UnitSpawn> spawns;
+    CoordSystem coordSystem = CoordSystem::Grid;
 };
 
 } // namespace Game::Map

+ 8 - 0
game/map/map_loader.cpp

@@ -4,6 +4,7 @@
 #include <QJsonDocument>
 #include <QJsonObject>
 #include <QJsonArray>
+#include <QString>
 
 namespace Game::Map {
 
@@ -66,6 +67,13 @@ bool MapLoader::loadFromJsonFile(const QString& path, MapDefinition& outMap, QSt
 
     outMap.name = root.value("name").toString("Unnamed Map");
 
+    // Optional: coordinate system for spawns
+    if (root.contains("coordSystem")) {
+        const QString cs = root.value("coordSystem").toString().trimmed().toLower();
+        if (cs == "world") outMap.coordSystem = CoordSystem::World;
+        else outMap.coordSystem = CoordSystem::Grid;
+    }
+
     if (root.contains("grid") && root.value("grid").isObject()) {
         if (!readGrid(root.value("grid").toObject(), outMap.grid)) {
             if (outError) *outError = "Invalid grid definition";

+ 77 - 26
game/map/map_transformer.cpp

@@ -1,45 +1,96 @@
 #include "map_transformer.h"
 
-#include "../../engine/core/world.h"
-#include "../../engine/core/component.h"
+#include "../core/world.h"
+#include "../core/component.h"
+#include "../units/factory.h"
+#include <QDebug>
+#include <QVector3D>
 
 namespace Game::Map {
 
+namespace { std::shared_ptr<Game::Units::UnitFactoryRegistry> s_registry; }
+
+void MapTransformer::setFactoryRegistry(std::shared_ptr<Game::Units::UnitFactoryRegistry> reg) { s_registry = std::move(reg); }
+std::shared_ptr<Game::Units::UnitFactoryRegistry> MapTransformer::getFactoryRegistry() { return s_registry; }
+
 MapRuntime MapTransformer::applyToWorld(const MapDefinition& def, Engine::Core::World& world, const Game::Visuals::VisualCatalog* visuals) {
     MapRuntime rt;
     rt.unitIds.reserve(def.spawns.size());
 
     for (const auto& s : def.spawns) {
-        auto* e = world.createEntity();
-        if (!e) continue;
-
-        auto* t = e->addComponent<Engine::Core::TransformComponent>();
-        t->position = {s.x, 0.0f, s.z};
-        t->scale = {0.5f, 0.5f, 0.5f};
-
-        auto* r = e->addComponent<Engine::Core::RenderableComponent>("", "");
-        r->visible = true;
-        // Apply visuals from catalog if provided, else use simple defaults
-        if (visuals) {
-            Game::Visuals::VisualDef def;
-            if (visuals->lookup(s.type.toStdString(), def)) {
-                Game::Visuals::applyToRenderable(def, *r);
+        // Compute world-space position
+        float worldX = s.x;
+        float worldZ = s.z;
+        if (def.coordSystem == CoordSystem::Grid) {
+            const float tile = std::max(0.0001f, def.grid.tileSize);
+            worldX = (s.x - (def.grid.width  * 0.5f - 0.5f)) * tile;
+            worldZ = (s.z - (def.grid.height * 0.5f - 0.5f)) * tile;
+        }
+
+        Engine::Core::Entity* e = nullptr;
+        if (s_registry) {
+            Game::Units::SpawnParams sp;
+            sp.position = QVector3D(worldX, 0.0f, worldZ);
+            sp.playerId = s.playerId;
+            sp.unitType = s.type.toStdString();
+            auto obj = s_registry->create(s.type.toStdString(), world, sp);
+            if (obj) {
+                e = world.getEntity(obj->id());
+                rt.unitIds.push_back(obj->id());
+                // destroy temporary wrapper; ECS holds state
             }
         }
-        if (r->color[0] == 0.0f && r->color[1] == 0.0f && r->color[2] == 0.0f) {
-            // fallback color if not set
-            r->color[0] = r->color[1] = r->color[2] = 1.0f;
+        if (!e) {
+            // Fallback manual path
+            e = world.createEntity();
+            if (!e) continue;
+            auto* t = e->addComponent<Engine::Core::TransformComponent>();
+            t->position = {worldX, 0.0f, worldZ};
+            t->scale = {0.5f, 0.5f, 0.5f};
+            auto* r = e->addComponent<Engine::Core::RenderableComponent>("", "");
+            r->visible = true;
+            auto* u = e->addComponent<Engine::Core::UnitComponent>();
+            u->unitType = s.type.toStdString();
+            u->ownerId = s.playerId;
+            // Team tint
+            QVector3D tc;
+            switch (u->ownerId) {
+                case 1: tc = QVector3D(0.20f, 0.55f, 1.00f); break; // blue
+                case 2: tc = QVector3D(1.00f, 0.30f, 0.30f); break; // red
+                case 3: tc = QVector3D(0.20f, 0.80f, 0.40f); break; // green
+                case 4: tc = QVector3D(1.00f, 0.80f, 0.20f); break; // yellow
+                default: tc = QVector3D(0.8f, 0.9f, 1.0f); break;
+            }
+            r->color[0] = tc.x(); r->color[1] = tc.y(); r->color[2] = tc.z();
+            if (s.type == "archer") {
+                u->health = 80; u->maxHealth = 80; u->speed = 3.0f;
+                auto* atk = e->addComponent<Engine::Core::AttackComponent>();
+                atk->range = 6.0f; atk->damage = 12; atk->cooldown = 1.2f;
+            }
+            e->addComponent<Engine::Core::MovementComponent>();
+            rt.unitIds.push_back(e->getId());
         }
 
-        auto* u = e->addComponent<Engine::Core::UnitComponent>();
-        u->unitType = s.type.toStdString();
-        if (s.type == "archer") {
-            u->health = 80; u->maxHealth = 80; u->speed = 3.0f;
+        // Apply visuals if available
+        if (auto* r = e->getComponent<Engine::Core::RenderableComponent>()) {
+            if (visuals) {
+                Game::Visuals::VisualDef defv;
+                if (visuals->lookup(s.type.toStdString(), defv)) {
+                    Game::Visuals::applyToRenderable(defv, *r);
+                }
+            }
+            if (r->color[0] == 0.0f && r->color[1] == 0.0f && r->color[2] == 0.0f) {
+                r->color[0] = r->color[1] = r->color[2] = 1.0f;
+            }
         }
 
-        e->addComponent<Engine::Core::MovementComponent>();
-
-        rt.unitIds.push_back(e->getId());
+        // Log spawn
+        if (auto* t = e->getComponent<Engine::Core::TransformComponent>()) {
+            qInfo() << "Spawned" << s.type
+                << "id=" << e->getId()
+                << "at" << QVector3D(t->position.x, t->position.y, t->position.z)
+                << "(coordSystem=" << (def.coordSystem == CoordSystem::Grid ? "Grid" : "World") << ")";
+        }
     }
 
     return rt;

+ 5 - 0
game/map/map_transformer.h

@@ -5,6 +5,7 @@
 #include <memory>
 
 namespace Engine { namespace Core { class World; using EntityID = unsigned int; } }
+namespace Game { namespace Units { class UnitFactoryRegistry; } }
 
 namespace Game::Map {
 
@@ -16,6 +17,10 @@ class MapTransformer {
 public:
     // Populates the world from a MapDefinition. Returns runtime info like created entity IDs.
     static MapRuntime applyToWorld(const MapDefinition& def, Engine::Core::World& world, const Game::Visuals::VisualCatalog* visuals = nullptr);
+
+    // Factory registry access (singleton-like for simplicity here)
+    static void setFactoryRegistry(std::shared_ptr<Game::Units::UnitFactoryRegistry> reg);
+    static std::shared_ptr<Game::Units::UnitFactoryRegistry> getFactoryRegistry();
 };
 
 } // namespace Game::Map

+ 37 - 0
game/systems/arrow_system.cpp

@@ -0,0 +1,37 @@
+#include "arrow_system.h"
+#include "../../render/gl/renderer.h"
+#include "../../render/gl/resources.h"
+#include "../../render/geom/arrow.h"
+#include <algorithm>
+
+namespace Game::Systems {
+
+ArrowSystem::ArrowSystem() {}
+
+void ArrowSystem::spawnArrow(const QVector3D& start, const QVector3D& end, const QVector3D& color, float speed) {
+    ArrowInstance a;
+    a.start = start;
+    a.end = end;
+    a.color = color;
+    a.t = 0.0f;
+    a.speed = speed;
+    a.active = true;
+    float dist = (end - start).length();
+    a.arcHeight = std::clamp(0.15f * dist, 0.2f, 1.2f);
+    m_arrows.push_back(a);
+}
+
+void ArrowSystem::update(Engine::Core::World* world, float deltaTime) {
+    for (auto& arrow : m_arrows) {
+        if (!arrow.active) continue;
+        arrow.t += deltaTime * arrow.speed / (arrow.start - arrow.end).length();
+        if (arrow.t >= 1.0f) {
+            arrow.t = 1.0f;
+            arrow.active = false;
+        }
+    }
+    // Remove inactive arrows
+    m_arrows.erase(std::remove_if(m_arrows.begin(), m_arrows.end(), [](const ArrowInstance& a){ return !a.active; }), m_arrows.end());
+}
+
+} // namespace Game::Systems

+ 29 - 0
game/systems/arrow_system.h

@@ -0,0 +1,29 @@
+#pragma once
+#include "../core/system.h"
+#include "../core/world.h"
+#include <vector>
+#include <QVector3D>
+
+namespace Game::Systems {
+
+struct ArrowInstance {
+    QVector3D start;
+    QVector3D end;
+    QVector3D color;
+    float t; // 0=start, 1=end
+    float speed;
+    bool active;
+    float arcHeight; // peak height of arc
+};
+
+class ArrowSystem : public Engine::Core::System {
+public:
+    ArrowSystem();
+    void update(Engine::Core::World* world, float deltaTime) override;
+    void spawnArrow(const QVector3D& start, const QVector3D& end, const QVector3D& color, float speed = 8.0f);
+    const std::vector<ArrowInstance>& arrows() const { return m_arrows; }
+private:
+    std::vector<ArrowInstance> m_arrows;
+};
+
+} // namespace Game::Systems

+ 67 - 10
game/systems/combat_system.cpp

@@ -1,6 +1,9 @@
 #include "combat_system.h"
-#include "../../engine/core/world.h"
-#include "../../engine/core/component.h"
+#include "../core/component.h"
+#include "../visuals/team_colors.h"
+#include "arrow_system.h"
+#include "../core/world.h"
+#include "../core/component.h"
 
 namespace Game::Systems {
 
@@ -10,28 +13,76 @@ void CombatSystem::update(Engine::Core::World* world, float deltaTime) {
 
 void CombatSystem::processAttacks(Engine::Core::World* world, float deltaTime) {
     auto units = world->getEntitiesWith<Engine::Core::UnitComponent>();
-    
+
+    // Find ArrowSystem if present
+    ArrowSystem* arrowSys = nullptr;
+    for (auto& sys : world->systems()) {
+        arrowSys = dynamic_cast<ArrowSystem*>(sys.get());
+        if (arrowSys) break;
+    }
+
     for (auto attacker : units) {
         auto attackerUnit = attacker->getComponent<Engine::Core::UnitComponent>();
         auto attackerTransform = attacker->getComponent<Engine::Core::TransformComponent>();
-        
+        auto attackerAtk = attacker->getComponent<Engine::Core::AttackComponent>();
+
         if (!attackerUnit || !attackerTransform) {
             continue;
         }
-        
-        // Simple AI: attack nearby enemies
+        // Skip dead attackers
+        if (attackerUnit->health <= 0) continue;
+        // If there's no attack component, use default melee-like behavior
+        float range = 2.0f;
+        int damage = 10;
+        float cooldown = 1.0f;
+        float* tAccum = nullptr;
+        float tmpAccum = 0.0f;
+        if (attackerAtk) {
+            range = std::max(0.1f, attackerAtk->range);
+            damage = std::max(0, attackerAtk->damage);
+            cooldown = std::max(0.05f, attackerAtk->cooldown);
+            attackerAtk->timeSinceLast += deltaTime;
+            tAccum = &attackerAtk->timeSinceLast;
+        } else {
+            tmpAccum += deltaTime;
+            tAccum = &tmpAccum;
+        }
+        if (*tAccum < cooldown) {
+            continue; // still cooling down
+        }
+
+        // Simple target selection: first valid target in range
         for (auto target : units) {
             if (target == attacker) {
                 continue;
             }
-            
+
             auto targetUnit = target->getComponent<Engine::Core::UnitComponent>();
             if (!targetUnit || targetUnit->health <= 0) {
                 continue;
             }
-            
-            if (isInRange(attacker, target, 2.0f)) {
-                dealDamage(target, 10); // Simple damage system
+            // Friendly-fire check: only attack units with a different owner
+            if (targetUnit->ownerId == attackerUnit->ownerId) {
+                continue;
+            }
+
+            if (isInRange(attacker, target, range)) {
+                // Arrow visual: spawn arrow if ArrowSystem present
+                if (arrowSys) {
+                    auto attT = attacker->getComponent<Engine::Core::TransformComponent>();
+                    auto tgtT = target->getComponent<Engine::Core::TransformComponent>();
+                    auto attU = attacker->getComponent<Engine::Core::UnitComponent>();
+                    QVector3D aPos(attT->position.x, attT->position.y, attT->position.z);
+                    QVector3D tPos(tgtT->position.x, tgtT->position.y, tgtT->position.z);
+                    QVector3D dir = (tPos - aPos).normalized();
+                    // Raise bow height and offset a bit forward to avoid intersecting the capsule body
+                    QVector3D start = aPos + QVector3D(0.0f, 0.6f, 0.0f) + dir * 0.35f;
+                    QVector3D end   = tPos + QVector3D(0.0f, 0.5f, 0.0f);
+                    QVector3D color = attU ? Game::Visuals::teamColorForOwner(attU->ownerId) : QVector3D(0.8f, 0.9f, 1.0f);
+                    arrowSys->spawnArrow(start, end, color, 14.0f);
+                }
+                dealDamage(target, damage);
+                *tAccum = 0.0f; // reset cooldown
                 break; // Only attack one target per update
             }
         }
@@ -57,6 +108,12 @@ void CombatSystem::dealDamage(Engine::Core::Entity* target, int damage) {
     auto unit = target->getComponent<Engine::Core::UnitComponent>();
     if (unit) {
         unit->health = std::max(0, unit->health - damage);
+        if (unit->health <= 0) {
+            // Hide the renderable so dead units disappear
+            if (auto* r = target->getComponent<Engine::Core::RenderableComponent>()) {
+                r->visible = false;
+            }
+        }
     }
 }
 

+ 3 - 1
game/systems/combat_system.h

@@ -1,6 +1,8 @@
 #pragma once
 
-#include "../../engine/core/system.h"
+#include "../core/system.h"
+
+namespace Engine { namespace Core { class Entity; } }
 
 namespace Game::Systems {
 

+ 29 - 34
game/systems/movement_system.cpp

@@ -26,50 +26,45 @@ void MovementSystem::moveUnit(Engine::Core::Entity* entity, float deltaTime) {
     const float accel = maxSpeed * 4.0f;   // time to accelerate to speed ~0.25s
     const float damping = 6.0f;            // critically-damped-ish
 
-    float dx = 0.0f;
-    float dz = 0.0f;
-    if (movement->hasTarget) {
-        dx = movement->targetX - transform->position.x;
-        dz = movement->targetY - transform->position.z; // Using z for 2D movement
-    }
-
-    float dist2 = dx*dx + dz*dz;
-    float distance = std::sqrt(dist2);
-
-    // Arrival radius for smooth stop
-    const float arriveRadius = 0.25f;
-
-    if (!movement->hasTarget || distance < arriveRadius) {
-        // No target or arrived: damp velocity to zero smoothly
+    // If there's no target, just damp velocity and keep position
+    if (!movement->hasTarget) {
         movement->vx *= std::max(0.0f, 1.0f - damping * deltaTime);
         movement->vz *= std::max(0.0f, 1.0f - damping * deltaTime);
+    } else {
+        float dx = movement->targetX - transform->position.x;
+        float dz = movement->targetY - transform->position.z; // Using z for 2D movement
+        float dist2 = dx*dx + dz*dz;
+        float distance = std::sqrt(dist2);
+        // Arrival radius for smooth stop
+        const float arriveRadius = 0.25f;
         if (distance < arriveRadius) {
+            // Arrived: snap to target and clear
             transform->position.x = movement->targetX;
             transform->position.z = movement->targetY;
             movement->hasTarget = false;
             movement->vx = movement->vz = 0.0f;
-        }
-    } else {
+        } else {
         // Desired velocity toward target with slowing within arrive radius*4
-        float nx = dx / std::max(0.0001f, distance);
-        float nz = dz / std::max(0.0001f, distance);
-        float desiredSpeed = maxSpeed;
-        float slowRadius = arriveRadius * 4.0f;
-        if (distance < slowRadius) {
-            desiredSpeed = maxSpeed * (distance / slowRadius);
-        }
-        float desiredVx = nx * desiredSpeed;
-        float desiredVz = nz * desiredSpeed;
+            float nx = dx / std::max(0.0001f, distance);
+            float nz = dz / std::max(0.0001f, distance);
+            float desiredSpeed = maxSpeed;
+            float slowRadius = arriveRadius * 4.0f;
+            if (distance < slowRadius) {
+                desiredSpeed = maxSpeed * (distance / slowRadius);
+            }
+            float desiredVx = nx * desiredSpeed;
+            float desiredVz = nz * desiredSpeed;
 
-        // accelerate toward desired velocity
-        float ax = (desiredVx - movement->vx) * accel;
-        float az = (desiredVz - movement->vz) * accel;
-        movement->vx += ax * deltaTime;
-        movement->vz += az * deltaTime;
+            // accelerate toward desired velocity
+            float ax = (desiredVx - movement->vx) * accel;
+            float az = (desiredVz - movement->vz) * accel;
+            movement->vx += ax * deltaTime;
+            movement->vz += az * deltaTime;
 
-        // apply damping
-        movement->vx *= std::max(0.0f, 1.0f - 0.5f * damping * deltaTime);
-        movement->vz *= std::max(0.0f, 1.0f - 0.5f * damping * deltaTime);
+            // apply damping
+            movement->vx *= std::max(0.0f, 1.0f - 0.5f * damping * deltaTime);
+            movement->vz *= std::max(0.0f, 1.0f - 0.5f * damping * deltaTime);
+        }
     }
 
     // Integrate position

+ 5 - 3
game/systems/movement_system.h

@@ -1,8 +1,10 @@
 #pragma once
 
-#include "../../engine/core/system.h"
-#include "../../engine/core/world.h"
-#include "../../engine/core/component.h"
+#include "../core/system.h"
+#include "../core/world.h"
+#include "../core/component.h"
+
+namespace Engine { namespace Core { class Entity; } }
 
 namespace Game::Systems {
 

+ 2 - 2
game/systems/selection_system.cpp

@@ -1,6 +1,6 @@
 #include "selection_system.h"
-#include "../../engine/core/world.h"
-#include "../../engine/core/component.h"
+#include "../core/world.h"
+#include "../core/component.h"
 #include <algorithm>
 
 namespace Game::Systems {

+ 5 - 1
game/systems/selection_system.h

@@ -1,6 +1,10 @@
 #pragma once
 
-#include "../../engine/core/system.h"
+#include "../core/system.h"
+#include "../core/entity.h"
+#include <vector>
+
+namespace Engine { namespace Core { class Entity; } }
 
 namespace Game::Systems {
 

+ 52 - 0
game/units/archer.cpp

@@ -0,0 +1,52 @@
+#include "archer.h"
+#include "../core/world.h"
+#include "../core/component.h"
+static inline QVector3D teamColor(int ownerId) {
+    switch (ownerId) {
+        case 1: return QVector3D(0.20f, 0.55f, 1.00f); // blue
+        case 2: return QVector3D(1.00f, 0.30f, 0.30f); // red
+        case 3: return QVector3D(0.20f, 0.80f, 0.40f); // green
+        case 4: return QVector3D(1.00f, 0.80f, 0.20f); // yellow
+        default: return QVector3D(0.8f, 0.9f, 1.0f);
+    }
+}
+
+namespace Game { namespace Units {
+
+Archer::Archer(Engine::Core::World& world)
+    : Unit(world, "archer") {}
+
+std::unique_ptr<Archer> Archer::Create(Engine::Core::World& world, const SpawnParams& params) {
+    auto unit = std::unique_ptr<Archer>(new Archer(world));
+    unit->init(params);
+    return unit;
+}
+
+void Archer::init(const SpawnParams& params) {
+    // Create ECS entity
+    auto* e = m_world->createEntity();
+    m_id = e->getId();
+
+    // Core components
+    m_t = e->addComponent<Engine::Core::TransformComponent>();
+    m_t->position = {params.position.x(), params.position.y(), params.position.z()};
+    m_t->scale = {0.5f, 0.5f, 0.5f};
+
+    m_r = e->addComponent<Engine::Core::RenderableComponent>("", "");
+    m_r->visible = true;
+
+    m_u = e->addComponent<Engine::Core::UnitComponent>();
+    m_u->unitType = m_type;
+    m_u->health = 80; m_u->maxHealth = 80; m_u->speed = 3.0f;
+    m_u->ownerId = params.playerId;
+    // Apply team color tint
+    QVector3D tc = teamColor(m_u->ownerId);
+    m_r->color[0] = tc.x(); m_r->color[1] = tc.y(); m_r->color[2] = tc.z();
+
+    m_mv = e->addComponent<Engine::Core::MovementComponent>();
+
+    m_atk = e->addComponent<Engine::Core::AttackComponent>();
+    m_atk->range = 6.0f; m_atk->damage = 12; m_atk->cooldown = 1.2f;
+}
+
+} } // namespace Game::Units

+ 17 - 0
game/units/archer.h

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

+ 12 - 0
game/units/factory.cpp

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

+ 28 - 0
game/units/factory.h

@@ -0,0 +1,28 @@
+#pragma once
+
+#include <functional>
+#include <memory>
+#include <string>
+#include <unordered_map>
+#include "unit.h"
+
+namespace Game { namespace Units {
+
+class UnitFactoryRegistry {
+public:
+    using Factory = std::function<std::unique_ptr<Unit>(Engine::Core::World&, const SpawnParams&)>;
+
+    void registerFactory(const std::string& type, Factory f) { m_map[type] = std::move(f); }
+    std::unique_ptr<Unit> create(const std::string& type, Engine::Core::World& world, const SpawnParams& params) const {
+        auto it = m_map.find(type);
+        if (it == m_map.end()) return nullptr;
+        return it->second(world, params);
+    }
+private:
+    std::unordered_map<std::string, Factory> m_map;
+};
+
+// Install built-in unit factories (archer, etc.)
+void registerBuiltInUnits(UnitFactoryRegistry& reg);
+
+} } // namespace Game::Units

+ 61 - 0
game/units/unit.cpp

@@ -0,0 +1,61 @@
+#include "unit.h"
+#include "../core/world.h"
+#include "../core/component.h"
+
+namespace Game { namespace Units {
+
+Unit::Unit(Engine::Core::World& world, const std::string& type)
+    : m_world(&world), m_type(type) {
+}
+
+Engine::Core::Entity* Unit::entity() const {
+    return m_world ? m_world->getEntity(m_id) : nullptr;
+}
+
+void Unit::ensureCoreComponents() {
+    if (!m_world) return;
+    if (auto* e = entity()) {
+        if (!m_t)  m_t  = e->getComponent<Engine::Core::TransformComponent>();
+        if (!m_r)  m_r  = e->getComponent<Engine::Core::RenderableComponent>();
+        if (!m_u)  m_u  = e->getComponent<Engine::Core::UnitComponent>();
+        if (!m_mv) m_mv = e->getComponent<Engine::Core::MovementComponent>();
+        if (!m_atk) m_atk = e->getComponent<Engine::Core::AttackComponent>();
+    }
+}
+
+void Unit::moveTo(float x, float z) {
+    ensureCoreComponents();
+    if (!m_mv) {
+        if (auto* e = entity()) m_mv = e->addComponent<Engine::Core::MovementComponent>();
+    }
+    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;
+    }
+    return false;
+}
+
+QVector3D Unit::position() const {
+    if (auto* e = entity()) {
+        if (auto* t = e->getComponent<Engine::Core::TransformComponent>())
+            return QVector3D(t->position.x, t->position.y, t->position.z);
+    }
+    return QVector3D();
+}
+
+} } // namespace Game::Units

+ 55 - 0
game/units/unit.h

@@ -0,0 +1,55 @@
+#pragma once
+
+#include <memory>
+#include <string>
+#include <utility>
+#include <QVector3D>
+
+namespace Engine { namespace Core {
+class World; class Entity; using EntityID = unsigned int;
+struct TransformComponent; struct RenderableComponent; struct UnitComponent; struct MovementComponent; struct AttackComponent;
+} }
+
+namespace Game { namespace Units {
+
+struct SpawnParams {
+    // world coordinates (XZ plane)
+    QVector3D position{0,0,0};
+    int playerId = 0;
+    std::string unitType; // optional label; renderer may use it
+};
+
+// Thin OOP facade over ECS components; no duplicate state.
+class Unit {
+public:
+    virtual ~Unit() = default;
+
+    Engine::Core::EntityID id() const { return m_id; }
+    const std::string& type() const { return m_type; }
+
+    // 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;
+
+protected:
+    Unit(Engine::Core::World& world, const std::string& type);
+    Engine::Core::Entity* entity() const; // cached but validated
+
+    // Helpers for derived classes to configure components
+    void ensureCoreComponents();
+
+    Engine::Core::World* m_world = nullptr;
+    Engine::Core::EntityID m_id = 0;
+    std::string m_type;
+    // Cached pointers (owned by ECS entity)
+    Engine::Core::TransformComponent* m_t = nullptr;
+    Engine::Core::RenderableComponent* m_r = nullptr;
+    Engine::Core::UnitComponent*       m_u = nullptr;
+    Engine::Core::MovementComponent*   m_mv = nullptr;
+    Engine::Core::AttackComponent*     m_atk = nullptr;
+};
+
+} } // namespace Game::Units

+ 14 - 0
game/visuals/team_colors.h

@@ -0,0 +1,14 @@
+#pragma once
+#include <QVector3D>
+
+namespace Game::Visuals {
+inline QVector3D teamColorForOwner(int ownerId) {
+    switch (ownerId) {
+        case 1: return QVector3D(0.20f, 0.55f, 1.00f); // blue
+        case 2: return QVector3D(1.00f, 0.30f, 0.30f); // red
+        case 3: return QVector3D(0.20f, 0.80f, 0.40f); // green
+        case 4: return QVector3D(1.00f, 0.80f, 0.20f); // yellow
+        default: return QVector3D(0.8f, 0.9f, 1.0f);
+    }
+}
+}

+ 1 - 1
game/visuals/visual_catalog.cpp

@@ -3,7 +3,7 @@
 #include <QJsonDocument>
 #include <QJsonObject>
 #include <QJsonArray>
-#include "../../engine/core/component.h"
+#include "../core/component.h"
 
 namespace Game::Visuals {
 

+ 1 - 0
render/CMakeLists.txt

@@ -9,6 +9,7 @@ add_library(render_gl STATIC
     entity/registry.cpp
     entity/archer_renderer.cpp
     geom/selection_ring.cpp
+    geom/arrow.cpp
 )
 
 target_include_directories(render_gl PUBLIC .)

+ 22 - 14
render/entity/archer_renderer.cpp

@@ -4,8 +4,13 @@
 #include "../gl/mesh.h"
 #include "../gl/texture.h"
 #include "../geom/selection_ring.h"
-#include "../../engine/core/component.h"
+#include "../../game/visuals/team_colors.h"
+#include "../../game/core/entity.h"
+#include "../../game/core/component.h"
 #include <QVector3D>
+#include <vector>
+#include <cmath>
+#include <memory>
 
 namespace Render::GL {
 
@@ -81,24 +86,27 @@ void registerArcherRenderer(EntityRendererRegistry& registry) {
     registry.registerRenderer("archer", [](const DrawParams& p){
         if (!p.renderer) return;
         QVector3D color(0.8f, 0.9f, 1.0f);
+        Engine::Core::UnitComponent* unit = nullptr;
+        Engine::Core::RenderableComponent* rc = nullptr;
         if (p.entity) {
-            if (auto* rc = p.entity->getComponent<Engine::Core::RenderableComponent>()) {
-                color = QVector3D(rc->color[0], rc->color[1], rc->color[2]);
-            }
+            unit = p.entity->getComponent<Engine::Core::UnitComponent>();
+            rc = p.entity->getComponent<Engine::Core::RenderableComponent>();
+        }
+        // Prefer team color based on ownerId if available; else fall back to Renderable color
+        if (unit && unit->ownerId > 0) {
+            color = Game::Visuals::teamColorForOwner(unit->ownerId);
+        } else if (rc) {
+            color = QVector3D(rc->color[0], rc->color[1], rc->color[2]);
         }
         // Draw capsule (archer body)
         p.renderer->drawMeshColored(getArcherCapsule(), p.model, color, nullptr);
         // Draw selection ring if selected
-        if (p.entity) {
-            if (auto* unit = p.entity->getComponent<Engine::Core::UnitComponent>()) {
-                if (unit->selected) {
-                    QMatrix4x4 ringM;
-                    QVector3D pos = p.model.column(3).toVector3D();
-                    ringM.translate(pos.x(), 0.01f, pos.z());
-                    ringM.scale(0.5f, 1.0f, 0.5f);
-                    p.renderer->drawMeshColored(Render::Geom::SelectionRing::get(), ringM, QVector3D(0.2f, 0.8f, 0.2f), nullptr);
-                }
-            }
+        if (unit && unit->selected) {
+            QMatrix4x4 ringM;
+            QVector3D pos = p.model.column(3).toVector3D();
+            ringM.translate(pos.x(), 0.01f, pos.z());
+            ringM.scale(0.5f, 1.0f, 0.5f);
+            p.renderer->drawMeshColored(Render::Geom::SelectionRing::get(), ringM, QVector3D(0.2f, 0.8f, 0.2f), nullptr);
         }
     });
 }

+ 2 - 2
render/entity/registry.cpp

@@ -1,8 +1,8 @@
 #include "registry.h"
 #include "archer_renderer.h"
 #include "../gl/renderer.h"
-#include "../../engine/core/entity.h"
-#include "../../engine/core/component.h"
+#include "../../game/core/entity.h"
+#include "../../game/core/component.h"
 
 namespace Render::GL {
 

+ 70 - 0
render/geom/arrow.cpp

@@ -0,0 +1,70 @@
+#include "arrow.h"
+#include "../gl/mesh.h"
+#include <vector>
+#include <cmath>
+#include <QVector3D>
+
+namespace Render::Geom {
+
+static Render::GL::Mesh* createArrowMesh() {
+    using Render::GL::Vertex;
+    std::vector<Vertex> verts;
+    std::vector<unsigned int> idx;
+    // Parameters for a simple arrow of length 1 along +Z
+    const int radial = 12;
+    const float shaftRadius = 0.05f;
+    const float shaftLen = 0.85f;   // 85% shaft, 15% tip
+    const float tipLen = 0.15f;
+    const float tipStartZ = shaftLen;
+    const float tipEndZ = shaftLen + tipLen; // should be 1.0
+    // Shaft cylinder: two rings at z=0 and z=shaftLen
+    int baseIndex = 0;
+    for (int ring = 0; ring < 2; ++ring) {
+        float z = (ring == 0) ? 0.0f : shaftLen;
+        for (int i = 0; i < radial; ++i) {
+            float a = (float(i) / radial) * 6.2831853f;
+            float x = std::cos(a) * shaftRadius;
+            float y = std::sin(a) * shaftRadius;
+            QVector3D n(x, y, 0.0f);
+            n.normalize();
+            verts.push_back({{x, y, z}, {n.x(), n.y(), n.z()}, {float(i)/radial, z}});
+        }
+    }
+    // Shaft indices (quads split into triangles)
+    for (int i = 0; i < radial; ++i) {
+        int next = (i + 1) % radial;
+        int a = baseIndex + i;
+        int b = baseIndex + next;
+        int c = baseIndex + radial + next;
+        int d = baseIndex + radial + i;
+        idx.push_back(a); idx.push_back(b); idx.push_back(c);
+        idx.push_back(c); idx.push_back(d); idx.push_back(a);
+    }
+    // Tip cone: triangle fan from tip apex to ring at tipStartZ
+    int ringStart = verts.size();
+    for (int i = 0; i < radial; ++i) {
+        float a = (float(i) / radial) * 6.2831853f;
+    float x = std::cos(a) * shaftRadius * 1.4f; // slightly larger base for tip
+    float y = std::sin(a) * shaftRadius * 1.4f;
+        QVector3D n(x, y, 0.2f);
+        n.normalize();
+        verts.push_back({{x, y, tipStartZ}, {n.x(), n.y(), n.z()}, {float(i)/radial, 0.0f}});
+    }
+    int apexIndex = verts.size();
+    verts.push_back({{0.0f, 0.0f, tipEndZ}, {0.0f, 0.0f, 1.0f}, {0.5f, 1.0f}});
+    for (int i = 0; i < radial; ++i) {
+        int next = (i + 1) % radial;
+        int a = ringStart + i;
+        int b = ringStart + next;
+        int apex = apexIndex;
+        idx.push_back(a); idx.push_back(apex); idx.push_back(b);
+    }
+    return new Render::GL::Mesh(verts, idx);
+}
+
+Render::GL::Mesh* Arrow::get() {
+    static Render::GL::Mesh* mesh = createArrowMesh();
+    return mesh;
+}
+
+} // namespace Render::Geom

+ 10 - 0
render/geom/arrow.h

@@ -0,0 +1,10 @@
+
+#pragma once
+#include "../gl/mesh.h"
+
+namespace Render::Geom {
+class Arrow {
+public:
+    static Render::GL::Mesh* get();
+};
+} // namespace Render::Geom

+ 127 - 17
render/gl/camera.cpp

@@ -1,5 +1,6 @@
 #include "camera.h"
 #include <QtMath>
+#include <cmath>
 
 namespace Render::GL {
 
@@ -59,7 +60,9 @@ void Camera::moveRight(float distance) {
 }
 
 void Camera::moveUp(float distance) {
-    m_position += m_up * distance;
+    // Elevate along world up to avoid roll artifacts
+    m_position += QVector3D(0,1,0) * distance;
+    clampAboveGround();
     m_target = m_position + m_front;
 }
 
@@ -76,26 +79,110 @@ void Camera::zoom(float delta) {
 }
 
 void Camera::rotate(float yaw, float pitch) {
-    // For RTS camera, we typically want to rotate around the target
+    // Delegate to orbit with clamping/world-up behavior
+    orbit(yaw, pitch);
+}
+
+void Camera::pan(float rightDist, float forwardDist) {
+    // Move along camera right and ground-projected front
+    QVector3D right = m_right;
+    QVector3D front = m_front; front.setY(0.0f); if (front.lengthSquared()>0) front.normalize();
+    QVector3D delta = right * rightDist + front * forwardDist;
+    m_position += delta;
+    m_target   += delta;
+    clampAboveGround();
+}
+
+void Camera::elevate(float dy) {
+    m_position.setY(m_position.y() + dy);
+    clampAboveGround();
+}
+
+void Camera::yaw(float degrees) {
+    // Pure yaw: keep current pitch and radius exactly
     QVector3D offset = m_position - m_target;
-    
-    // Apply yaw rotation (around Y axis)
-    QMatrix4x4 yawRotation;
-    yawRotation.rotate(yaw, QVector3D(0, 1, 0));
-    
-    // Apply pitch rotation (around local X axis)
-    QVector3D rightAxis = QVector3D::crossProduct(offset, m_up).normalized();
-    QMatrix4x4 pitchRotation;
-    pitchRotation.rotate(pitch, rightAxis);
-    
-    // Combine rotations
-    offset = pitchRotation.map(yawRotation.map(offset));
-    m_position = m_target + offset;
-    
+    float curYaw=0.f, curPitch=0.f;
+    computeYawPitchFromOffset(offset, curYaw, curPitch);
+    orbit(degrees, 0.0f);
+}
+
+void Camera::orbit(float yawDeg, float pitchDeg) {
+    // Rotate around target with pitch clamped and world-up locked
+    QVector3D offset = m_position - m_target;
+    if (offset.lengthSquared() < 1e-6f) {
+        // Nudge back if somehow at target
+        offset = QVector3D(0, 0, 1);
+    }
+    float curYaw=0.f, curPitch=0.f;
+    computeYawPitchFromOffset(offset, curYaw, curPitch);
+    float newYaw = curYaw + yawDeg;
+    float newPitch = qBound(m_pitchMinDeg, curPitch + pitchDeg, m_pitchMaxDeg);
+    float r = offset.length();
+    float yawRad = qDegreesToRadians(newYaw);
+    float pitchRad = qDegreesToRadians(newPitch);
+    QVector3D newDir(
+        std::sin(yawRad) * std::cos(pitchRad),
+        std::sin(pitchRad),
+        std::cos(yawRad) * std::cos(pitchRad)
+    );
+    m_position = m_target - newDir.normalized() * r;
+    clampAboveGround();
     m_front = (m_target - m_position).normalized();
     updateVectors();
 }
 
+bool Camera::screenToGround(float sx, float sy, float screenW, float screenH, QVector3D& outWorld) const {
+    if (screenW <= 0 || screenH <= 0) return false;
+    float x = (2.0f * sx / screenW) - 1.0f;
+    float y = 1.0f - (2.0f * sy / screenH);
+    bool ok=false;
+    QMatrix4x4 invVP = (getProjectionMatrix() * getViewMatrix()).inverted(&ok);
+    if (!ok) return false;
+    QVector4D nearClip(x, y, 0.0f, 1.0f);
+    QVector4D farClip (x, y, 1.0f, 1.0f);
+    QVector4D nearWorld4 = invVP * nearClip;
+    QVector4D farWorld4  = invVP * farClip;
+    if (nearWorld4.w() == 0.0f || farWorld4.w() == 0.0f) return false;
+    QVector3D rayOrigin = (nearWorld4 / nearWorld4.w()).toVector3D();
+    QVector3D rayEnd    = (farWorld4  / farWorld4.w()).toVector3D();
+    QVector3D rayDir    = (rayEnd - rayOrigin).normalized();
+    if (qFuzzyIsNull(rayDir.y())) return false;
+    // Intersect with plane y = m_groundY
+    float t = (m_groundY - rayOrigin.y()) / rayDir.y();
+    if (t < 0.0f) return false;
+    outWorld = rayOrigin + rayDir * t;
+    return true;
+}
+
+bool Camera::worldToScreen(const QVector3D& world, int screenW, int screenH, QPointF& outScreen) const {
+    if (screenW <= 0 || screenH <= 0) return false;
+    QVector4D clip = getProjectionMatrix() * getViewMatrix() * QVector4D(world, 1.0f);
+    if (clip.w() == 0.0f) return false;
+    QVector3D ndc = (clip / clip.w()).toVector3D();
+    if (ndc.z() < -1.0f || ndc.z() > 1.0f) return false;
+    float sx = (ndc.x() * 0.5f + 0.5f) * float(screenW);
+    float sy = (1.0f - (ndc.y() * 0.5f + 0.5f)) * float(screenH);
+    outScreen = QPointF(sx, sy);
+    return true;
+}
+
+void Camera::updateFollow(const QVector3D& targetCenter) {
+    if (!m_followEnabled) return;
+    if (m_followOffset.lengthSquared() < 1e-5f) {
+        // Initialize offset from current camera state
+        m_followOffset = m_position - m_target;
+    }
+    QVector3D desiredPos = targetCenter + m_followOffset;
+    QVector3D newPos = (m_followLerp >= 0.999f) ? desiredPos
+                        : (m_position + (desiredPos - m_position) * m_followLerp);
+    m_target = targetCenter;
+    m_position = newPos;
+    // Always look at the target while following
+    m_front = (m_target - m_position).normalized();
+    clampAboveGround();
+    updateVectors();
+}
+
 void Camera::setRTSView(const QVector3D& center, float distance, float angle) {
     m_target = center;
     
@@ -139,8 +226,31 @@ QMatrix4x4 Camera::getViewProjectionMatrix() const {
 }
 
 void Camera::updateVectors() {
-    m_right = QVector3D::crossProduct(m_front, m_up).normalized();
+    // Keep camera upright: lock roll by using world up for building right vector
+    QVector3D worldUp(0,1,0);
+    // If front is parallel to worldUp, skip to a safe right
+    QVector3D r = QVector3D::crossProduct(m_front, worldUp);
+    if (r.lengthSquared() < 1e-6f) r = QVector3D(1,0,0);
+    m_right = r.normalized();
     m_up = QVector3D::crossProduct(m_right, m_front).normalized();
 }
 
+void Camera::clampAboveGround() {
+    if (m_position.y() < m_groundY + m_minHeight) {
+        m_position.setY(m_groundY + m_minHeight);
+    }
+}
+
+void Camera::computeYawPitchFromOffset(const QVector3D& off, float& yawDeg, float& pitchDeg) const {
+    QVector3D dir = -off; // look direction from position to target
+    if (dir.lengthSquared() < 1e-6f) {
+        yawDeg = 0.f; pitchDeg = 0.f; return;
+    }
+    float yaw = qRadiansToDegrees(std::atan2(dir.x(), dir.z()));
+    float lenXZ = std::sqrt(dir.x()*dir.x() + dir.z()*dir.z());
+    float pitch = qRadiansToDegrees(std::atan2(dir.y(), lenXZ));
+    yawDeg = yaw;
+    pitchDeg = pitch;
+}
+
 } // namespace Render::GL

+ 31 - 0
render/gl/camera.h

@@ -2,6 +2,7 @@
 
 #include <QMatrix4x4>
 #include <QVector3D>
+#include <QPointF>
 
 namespace Render::GL {
 
@@ -25,6 +26,21 @@ public:
     void moveUp(float distance);
     void zoom(float delta);
     void rotate(float yaw, float pitch);
+    // High-level RTS utilities
+    void pan(float rightDist, float forwardDist);   // move position & target along right and ground-forward (XZ)
+    void elevate(float dy);                         // move position vertically; target unchanged
+    void yaw(float degrees);                        // rotate around target (yaw only)
+    void orbit(float yawDeg, float pitchDeg);       // rotate around target (yaw & pitch)
+    bool screenToGround(float sx, float sy, float screenW, float screenH, QVector3D& outWorld) const; // ray-plane y=0
+    bool worldToScreen(const QVector3D& world, int screenW, int screenH, QPointF& outScreen) const;    // to pixels
+
+    // Follow helpers (kept in camera to avoid UI/engine low-level math)
+    void setFollowEnabled(bool enable) { m_followEnabled = enable; }
+    bool isFollowEnabled() const { return m_followEnabled; }
+    void setFollowLerp(float alpha) { m_followLerp = alpha; }
+    void setFollowOffset(const QVector3D& off) { m_followOffset = off; }
+    void captureFollowOffset() { m_followOffset = m_position - m_target; }
+    void updateFollow(const QVector3D& targetCenter); // call each frame with current target center
     
     // RTS camera presets
     void setRTSView(const QVector3D& center, float distance = 10.0f, float angle = 45.0f);
@@ -63,7 +79,22 @@ private:
     float m_orthoBottom = -10.0f;
     float m_orthoTop = 10.0f;
     
+    // Follow state
+    bool m_followEnabled = false;
+    QVector3D m_followOffset{0,0,0};
+    float m_followLerp = 0.15f;
+
+    // RTS constraints
+    float m_groundY = 0.0f;
+    float m_minHeight = 0.5f;          // do not go below this height above ground
+    // Pitch is measured from the XZ plane; negative means looking down.
+    // Clamp to a downward-looking range to keep ground at bottom and avoid flips.
+    float m_pitchMinDeg = -85.0f;      // min (most downward) pitch
+    float m_pitchMaxDeg = -5.0f;       // max (least downward) pitch
+
     void updateVectors();
+    void clampAboveGround();
+    void computeYawPitchFromOffset(const QVector3D& off, float& yawDeg, float& pitchDeg) const;
 };
 
 } // namespace Render::GL

+ 13 - 91
render/gl/renderer.cpp

@@ -1,6 +1,6 @@
 #include "renderer.h"
-#include "../../engine/core/world.h"
-#include "../../engine/core/component.h"
+#include "../../game/core/world.h"
+#include "../../game/core/component.h"
 #include <QDebug>
 #include <QOpenGLContext>
 #include <algorithm>
@@ -247,102 +247,26 @@ void Renderer::renderWorld(Engine::Core::World* world) {
 }
 
 bool Renderer::loadShaders() {
-    // Basic vertex shader
-    QString basicVertexSource = R"(
-        #version 330 core
-        layout (location = 0) in vec3 a_position;
-        layout (location = 1) in vec3 a_normal;
-        layout (location = 2) in vec2 a_texCoord;
-        
-        uniform mat4 u_model;
-        uniform mat4 u_view;
-        uniform mat4 u_projection;
-        
-        out vec3 v_normal;
-        out vec2 v_texCoord;
-        out vec3 v_worldPos;
-        
-        void main() {
-            v_normal = mat3(transpose(inverse(u_model))) * a_normal;
-            v_texCoord = a_texCoord;
-            v_worldPos = vec3(u_model * vec4(a_position, 1.0));
-            
-            gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);
-        }
-    )";
-    
-    // Basic fragment shader
-    QString basicFragmentSource = R"(
-        #version 330 core
-        in vec3 v_normal;
-        in vec2 v_texCoord;
-        in vec3 v_worldPos;
-        
-        uniform sampler2D u_texture;
-        uniform vec3 u_color;
-        uniform bool u_useTexture;
-        
-        out vec4 FragColor;
-        
-        void main() {
-            vec3 color = u_color;
-            
-            if (u_useTexture) {
-                color *= texture(u_texture, v_texCoord).rgb;
-            }
-            
-            // Simple lighting
-            vec3 normal = normalize(v_normal);
-            vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
-            float diff = max(dot(normal, lightDir), 0.2); // Ambient + diffuse
-            
-            color *= diff;
-            
-            FragColor = vec4(color, 1.0);
-        }
-    )";
-    
+    // Load shaders from assets
+    const QString base = QStringLiteral("assets/shaders/");
+    const QString basicVert = base + QStringLiteral("basic.vert");
+    const QString basicFrag = base + QStringLiteral("basic.frag");
+    const QString gridFrag  = base + QStringLiteral("grid.frag");
+
     m_basicShader = std::make_unique<Shader>();
-    if (!m_basicShader->loadFromSource(basicVertexSource, basicFragmentSource)) {
-        qWarning() << "Failed to load basic shader";
+    if (!m_basicShader->loadFromFiles(basicVert, basicFrag)) {
+        qWarning() << "Failed to load basic shader from files" << basicVert << basicFrag;
         return false;
     }
-    // Grid shader
-    QString gridVertex = basicVertexSource;
-    QString gridFragment = R"(
-        #version 330 core
-        in vec3 v_normal;
-        in vec2 v_texCoord;
-        in vec3 v_worldPos;
-        
-        uniform vec3 u_gridColor;
-        uniform vec3 u_lineColor;
-        uniform float u_cellSize;
-        uniform float u_thickness; // fraction of cell (0..0.5)
-        
-        out vec4 FragColor;
-        
-        void main() {
-            vec2 coord = v_worldPos.xz / u_cellSize;
-            vec2 g = abs(fract(coord) - 0.5);
-            float lineX = step(0.5 - u_thickness, g.x);
-            float lineY = step(0.5 - u_thickness, g.y);
-            float lineMask = max(lineX, lineY);
-            vec3 col = mix(u_gridColor, u_lineColor, lineMask);
-            FragColor = vec4(col, 1.0);
-        }
-    )";
     m_gridShader = std::make_unique<Shader>();
-    if (!m_gridShader->loadFromSource(gridVertex, gridFragment)) {
-        qWarning() << "Failed to load grid shader";
-        // Non-fatal
+    // Reuse the same vertex shader for grid; load vertex from file and fragment from grid
+    if (!m_gridShader->loadFromFiles(basicVert, gridFrag)) {
+        qWarning() << "Failed to load grid shader from files" << basicVert << gridFrag;
         m_gridShader.reset();
     }
     return true;
 }
 
-// Removed: default resource creation is now handled by ResourceManager
-
 void Renderer::sortRenderQueue() {
     // Simple sorting by texture to reduce state changes
     std::sort(m_renderQueue.begin(), m_renderQueue.end(),
@@ -351,6 +275,4 @@ void Renderer::sortRenderQueue() {
         });
 }
 
-// Selection ring helper removed; entity renderers can draw ring using drawMeshColored with shared ring mesh
-
 } // namespace Render::GL

+ 3 - 0
render/gl/resources.h

@@ -5,6 +5,8 @@
 #include "mesh.h"
 #include "texture.h"
 
+#include "../geom/arrow.h"
+
 namespace Render::GL {
 
 class ResourceManager : protected QOpenGLFunctions_3_3_Core {
@@ -18,6 +20,7 @@ public:
     Mesh* quad() const { return m_quadMesh.get(); }
     Mesh* ground() const { return m_groundMesh.get(); }
     Mesh* unit() const { return m_unitMesh.get(); }
+    Mesh* arrow() const { return Render::Geom::Arrow::get(); }
     Texture* white() const { return m_whiteTexture.get(); }
 
 private:

+ 231 - 77
scripts/setup-deps.sh

@@ -1,6 +1,6 @@
 #!/usr/bin/env bash
 
-# Standard-of-Iron — dependency checker and auto-installer (Debian/Ubuntu family)
+# Standard-of-Iron — dependency checker and auto-installer (Debian/Ubuntu + Arch/Manjaro)
 #
 # Verifies required toolchain and Qt/QML runtime modules and installs any missing ones.
 # Safe to run multiple times. Requires sudo privileges for installation.
@@ -75,23 +75,35 @@ read_os_release() {
   fi
 }
 
-has_apt() { command -v apt-get >/dev/null 2>&1; }
+has_apt()    { command -v apt-get >/dev/null 2>&1; }
+has_pacman() { command -v pacman  >/dev/null 2>&1; }
 
 is_deb_family_exact() {
   case "$1" in
-    ubuntu|debian) return 0 ;;
+    ubuntu|debian|linuxmint|pop) return 0 ;;
     *) return 1 ;;
   esac
 }
-
 is_deb_family_like() {
-  # $1: ID_LIKE string
   case " $1 " in
     *" debian "*|*" ubuntu "*) return 0 ;;
     *) return 1 ;;
   esac
 }
 
+is_arch_family_exact() {
+  case "$1" in
+    arch|manjaro|endeavouros|garuda) return 0 ;;
+    *) return 1 ;;
+  esac
+}
+is_arch_family_like() {
+  case " $1 " in
+    *" arch "*) return 0 ;;
+    *) return 1 ;;
+  esac
+}
+
 detect_distro() {
   local id like pretty
   while IFS='=' read -r k v; do
@@ -102,7 +114,6 @@ detect_distro() {
     esac
   done < <(read_os_release)
 
-  # strip quotes if present
   id=${id%\"}; id=${id#\"}
   like=${like%\"}; like=${like#\"}
   pretty=${pretty%\"}; pretty=${pretty#\"}
@@ -110,7 +121,9 @@ detect_distro() {
   echo "$id" "$like" "$pretty"
 }
 
-# Base toolchain and common libs
+# ===================== Package maps =====================
+
+# APT (Debian/Ubuntu) toolchain + GL/Vulkan
 APT_PKGS=(
   build-essential
   cmake
@@ -123,7 +136,7 @@ APT_PKGS=(
   libegl1
 )
 
-# Qt6 development headers/tools (filtered for availability later)
+# APT Qt6 dev headers/tools (filtered later)
 QT6_DEV_PKGS=(
   qt6-base-dev
   qt6-base-dev-tools
@@ -132,7 +145,7 @@ QT6_DEV_PKGS=(
   qt6-tools-dev-tools
 )
 
-# Qt6 QML runtime modules (filtered for availability)
+# APT Qt6 QML runtime modules (filtered)
 QT6_QML_RUN_PKGS=(
   qml6-module-qtqml
   qml6-module-qtquick
@@ -143,7 +156,7 @@ QT6_QML_RUN_PKGS=(
   qml6-module-qtquick-templates
 )
 
-# Fallback Qt5 QML runtime modules (only installed if present in repos)  
+# APT Qt5 fallback (filtered)
 QT5_QML_RUN_PKGS=(
   qml-module-qtqml
   qml-module-qtquick2
@@ -154,11 +167,44 @@ QT5_QML_RUN_PKGS=(
   qtdeclarative5-dev
 )
 
-apt_pkg_available() {
-  apt-cache show "$1" >/dev/null 2>&1
-}
+# PACMAN (Arch/Manjaro) toolchain + GL/Vulkan
+PAC_PKGS=(
+  base-devel      # build tools incl. gcc/g++
+  cmake
+  git
+  pkgconf         # provides pkg-config
+  mesa            # GL/EGL/GLX (via libglvnd)
+  mesa-demos      # glxinfo/glxgears (like mesa-utils)
+  libglvnd
+  vulkan-icd-loader
+  vulkan-tools
+)
 
-filter_available_pkgs() {
+# PACMAN Qt6 dev / runtime
+QT6_DEV_PAC=(
+  qt6-base
+  qt6-declarative
+  qt6-tools
+  qt6-shadertools
+)
+
+QT6_QML_RUN_PAC=(
+  qt6-declarative
+  qt6-quickcontrols2
+)
+
+# PACMAN Qt5 fallback
+QT5_QML_RUN_PAC=(
+  qt5-base
+  qt5-declarative
+  qt5-quickcontrols2
+)
+
+# ===================== APT helpers =====================
+
+apt_pkg_available() { apt-cache show "$1" >/dev/null 2>&1; }
+
+filter_available_pkgs_apt() {
   local out=() p
   for p in "$@"; do
     if apt_pkg_available "$p"; then
@@ -168,34 +214,7 @@ filter_available_pkgs() {
   printf '%s\n' "${out[@]}"
 }
 
-check_tool_versions() {
-  info "Checking toolchain versions"
-
-  need_cmd cmake
-  local cmv
-  cmv=$(cmake --version | head -n1 | awk '{print $3}')
-  if semver_ge "$cmv" "$MIN_CMAKE"; then
-    ok "cmake $cmv (>= $MIN_CMAKE)"
-  else
-    warn "cmake $cmv (< $MIN_CMAKE) — will attempt to install newer via apt"
-  fi
-
-  need_cmd g++
-  local gxv
-  gxv=$(g++ --version | head -n1 | awk '{print $NF}')
-  if semver_ge "$gxv" "$MIN_GXX"; then
-    ok "g++ $gxv (>= $MIN_GXX)"
-  else
-    warn "g++ $gxv (< $MIN_GXX) — will attempt to install build-essential"
-  fi
-
-  need_cmd git; ok "git $(git --version | awk '{print $3}')"
-  need_cmd pkg-config; ok "pkg-config $(pkg-config --version)"
-}
-
-dpkg_installed() {
-  dpkg -s "$1" >/dev/null 2>&1
-}
+dpkg_installed() { dpkg -s "$1" >/dev/null 2>&1; }
 
 apt_update_once() {
   if [ "${_APT_UPDATED:-0}" != 1 ]; then
@@ -211,10 +230,9 @@ apt_update_once() {
 
 apt_install() {
   local to_install=()
+  local pkg
   for pkg in "$@"; do
-    if [ -z "${pkg:-}" ]; then
-      continue
-    fi
+    [ -n "${pkg:-}" ] || continue
     if dpkg_installed "$pkg"; then
       ok "$pkg already installed"
     else
@@ -230,80 +248,216 @@ apt_install() {
     if ! $ASSUME_YES; then
       echo
       read -r -p "Install missing packages: ${to_install[*]} ? [Y/n] " ans
-      case "${ans:-Y}" in
-        y|Y) ;;
-        *) warn "User declined install"; return 0 ;;
-      esac
+      case "${ans:-Y}" in y|Y) ;; *) warn "User declined install"; return 0 ;; esac
     fi
     apt_update_once
     info "Installing: ${to_install[*]}"
     if $DRY_RUN; then
-      echo "sudo apt-get install -y ${to_install[*]}"
+      echo "DEBIAN_FRONTEND=noninteractive sudo apt-get install -y ${to_install[*]}"
     else
       DEBIAN_FRONTEND=noninteractive sudo apt-get install -y "${to_install[@]}"
     fi
   fi
 }
 
-check_qt_runtime() {
-  info "Installing base toolchain"
+# ===================== PACMAN helpers =====================
+
+pacman_pkg_available() { sudo pacman -Si "$1" >/dev/null 2>&1 || pacman -Si "$1" >/dev/null 2>&1; }
+
+filter_available_pkgs_pacman() {
+  local out=() p
+  for p in "$@"; do
+    if pacman_pkg_available "$p"; then
+      out+=("$p")
+    fi
+  done
+  printf '%s\n' "${out[@]}"
+}
+
+pacman_installed() { pacman -Qi "$1" >/dev/null 2>&1; }
+
+pacman_update_once() {
+  if [ "${_PAC_UPDATED:-0}" != 1 ]; then
+    info "Refreshing pacman package databases"
+    if $DRY_RUN; then
+      echo "sudo pacman -Sy"
+    else
+      sudo pacman -Sy --noconfirm
+    fi
+    _PAC_UPDATED=1
+  fi
+}
+
+pacman_install() {
+  local to_install=()
+  local pkg
+  for pkg in "$@"; do
+    [ -n "${pkg:-}" ] || continue
+    if pacman_installed "$pkg"; then
+      ok "$pkg already installed"
+    else
+      to_install+=("$pkg")
+    fi
+  done
+
+  if [ ${#to_install[@]} -gt 0 ]; then
+    if $NO_INSTALL; then
+      warn "Missing packages: ${to_install[*]} (skipping install due to --no-install)"
+      return 0
+    fi
+    if ! $ASSUME_YES; then
+      echo
+      read -r -p "Install missing packages (pacman): ${to_install[*]} ? [Y/n] " ans
+      case "${ans:-Y}" in y|Y) ;; *) warn "User declined install"; return 0 ;; esac
+    fi
+    pacman_update_once
+    info "Installing: ${to_install[*]}"
+    local args=(-S --needed)
+    $ASSUME_YES && args+=(--noconfirm)
+    if $DRY_RUN; then
+      echo "sudo pacman ${args[*]} ${to_install[*]}"
+    else
+      sudo pacman "${args[@]}" "${to_install[@]}"
+    fi
+  fi
+}
+
+# ===================== Checks =====================
+
+check_tool_versions() {
+  info "Checking toolchain versions"
+
+  need_cmd cmake
+  local cmv
+  cmv=$(cmake --version | head -n1 | awk '{print $3}')
+  if semver_ge "$cmv" "$MIN_CMAKE"; then
+    ok "cmake $cmv (>= $MIN_CMAKE)"
+  else
+    warn "cmake $cmv (< $MIN_CMAKE) — will attempt to install newer via package manager"
+  fi
+
+  need_cmd g++
+  local gxv
+  gxv=$(g++ --version | head -n1 | awk '{print $NF}')
+  if semver_ge "$gxv" "$MIN_GXX"; then
+    ok "g++ $gxv (>= $MIN_GXX)"
+  else
+    warn "g++ $gxv (< $MIN_GXX) — will attempt to install compiler toolchain"
+  fi
+
+  need_cmd git; ok "git $(git --version | awk '{print $3}')"
+  local PKG_CMD=""
+  if command -v pkg-config >/dev/null 2>&1; then
+    PKG_CMD="pkg-config"
+  elif command -v pkgconf >/dev/null 2>&1; then
+    PKG_CMD="pkgconf"
+  else
+    error "Neither pkg-config nor pkgconf is installed. Please install one of them and rerun this script."
+    exit 1
+  fi
+  ok "$PKG_CMD $($PKG_CMD --version)"
+}
+
+install_runtime_apt() {
+  info "Installing base toolchain (APT)"
   apt_install "${APT_PKGS[@]}"
 
-  info "Installing Qt6 SDK/dev packages"
-  mapfile -t _qt6dev < <(filter_available_pkgs "${QT6_DEV_PKGS[@]}")
+  info "Installing Qt6 SDK/dev packages (APT)"
+  mapfile -t _qt6dev < <(filter_available_pkgs_apt "${QT6_DEV_PKGS[@]}")
   apt_install "${_qt6dev[@]}"
 
-  info "Installing Qt6 QML runtime modules"
-  mapfile -t _qt6qml < <(filter_available_pkgs "${QT6_QML_RUN_PKGS[@]}")
+  info "Installing Qt6 QML runtime modules (APT)"
+  mapfile -t _qt6qml < <(filter_available_pkgs_apt "${QT6_QML_RUN_PKGS[@]}")
   apt_install "${_qt6qml[@]}"
 
-  info "Installing Qt5 QML runtime modules (fallback, if available)"
-  mapfile -t _qt5qml < <(filter_available_pkgs "${QT5_QML_RUN_PKGS[@]}")
+  info "Installing Qt5 QML runtime modules (fallback, if available; APT)"
+  mapfile -t _qt5qml < <(filter_available_pkgs_apt "${QT5_QML_RUN_PKGS[@]}")
   apt_install "${_qt5qml[@]}"
 }
 
+install_runtime_pacman() {
+  info "Installing base toolchain (pacman)"
+  pacman_install "${PAC_PKGS[@]}"
+
+  info "Installing Qt6 SDK/dev packages (pacman)"
+  mapfile -t _qt6dev < <(filter_available_pkgs_pacman "${QT6_DEV_PAC[@]}")
+  pacman_install "${_qt6dev[@]}"
+
+  info "Ensuring Qt6 QML runtime (pacman)"
+  mapfile -t _qt6qml < <(filter_available_pkgs_pacman "${QT6_QML_RUN_PAC[@]}")
+  pacman_install "${_qt6qml[@]}"
+
+  info "Installing Qt5 QML runtime (fallback, if available; pacman)"
+  mapfile -t _qt5qml < <(filter_available_pkgs_pacman "${QT5_QML_RUN_PAC[@]}")
+  pacman_install "${_qt5qml[@]}"
+}
+
 main() {
   local id like pretty
   read -r id like pretty < <(detect_distro)
 
   info "Detected system: ${pretty:-$id} (ID=$id; ID_LIKE='${like:-}')."
 
+  local pm=""
   if is_deb_family_exact "$id"; then
-    info "Exact Debian/Ubuntu family detected ($id)."
+    pm="apt"; info "Exact Debian/Ubuntu family detected ($id)."
+  elif is_arch_family_exact "$id"; then
+    pm="pacman"; info "Exact Arch/Manjaro family detected ($id)."
   elif is_deb_family_like "${like:-}" && has_apt; then
+    pm="apt"
     warn "No exact match, but this system is *similar* to Debian/Ubuntu and has apt-get."
     if $ALLOW_SIMILAR || $ASSUME_YES; then
       info "Proceeding with Debian/Ubuntu-compatible steps due to --allow-similar/--yes."
     else
       echo
       read -r -p "Proceed using Debian/Ubuntu package set on this similar distro? [Y/n] " ans
-      case "${ans:-Y}" in
-        y|Y) info "Continuing with Debian/Ubuntu-compatible steps." ;;
-        *) err "User declined proceeding on a similar distro."; exit 1 ;;
-      esac
+      case "${ans:-Y}" in y|Y) info "Continuing with Debian/Ubuntu-compatible steps." ;;
+        *) err "User declined proceeding on a similar distro."; exit 1 ;; esac
+    fi
+  elif is_arch_family_like "${like:-}" && has_pacman; then
+    pm="pacman"
+    warn "No exact match, but this system is *similar* to Arch and has pacman."
+    if $ALLOW_SIMILAR || $ASSUME_YES; then
+      info "Proceeding with Arch-compatible steps due to --allow-similar/--yes."
+    else
+      echo
+      read -r -p "Proceed using Arch/Manjaro package set on this similar distro? [Y/n] " ans
+      case "${ans:-Y}" in y|Y) info "Continuing with Arch-compatible steps." ;;
+        *) err "User declined proceeding on a similar distro."; exit 1 ;; esac
     fi
   else
-    warn "Unsupported distro '$id'. This script targets apt-based Debian/Ubuntu family."
+    # Fall back based on package manager presence
     if has_apt; then
-      warn "apt-get is present, but ID/ID_LIKE do not indicate Debian/Ubuntu."
-      if $ALLOW_SIMILAR || $ASSUME_YES; then
-        info "Proceeding anyway due to --allow-similar/--yes."
-      else
-        read -r -p "Proceed anyway using apt? (may or may not work) [y/N] " ans
-        case "${ans:-N}" in
-          y|Y) info "Continuing with apt-based steps." ;;
-          *) err "Exiting to avoid misconfiguration. Use --allow-similar to override."; exit 1 ;;
-        esac
-      fi
+      pm="apt"
+      warn "Unknown distro '$id', but apt-get is present."
+      $ALLOW_SIMILAR || $ASSUME_YES || {
+        read -r -p "Proceed using apt-based steps? (may or may not work) [y/N] " ans
+        case "${ans:-N}" in y|Y) ;; *) err "Exiting. Use --allow-similar to override."; exit 1 ;; esac
+      }
+    elif has_pacman; then
+      pm="pacman"
+      warn "Unknown distro '$id', but pacman is present."
+      $ALLOW_SIMILAR || $ASSUME_YES || {
+        read -r -p "Proceed using pacman-based steps? (may or may not work) [y/N] " ans
+        case "${ans:-N}" in y|Y) ;; *) err "Exiting. Use --allow-similar to override."; exit 1 ;; esac
+      }
     else
-      err "No apt-get found and distro is not Debian/Ubuntu-like. Please install equivalent packages manually:
-${APT_PKGS[*]} ${QT6_DEV_PKGS[*]} ${QT6_QML_RUN_PKGS[*]}"
+      err "No supported package manager found (apt-get or pacman). Please install equivalents manually."
+      echo "Required (roughly):"
+      echo " - build tools (gcc/g++), cmake >= $MIN_CMAKE, git, pkg-config"
+      echo " - OpenGL/EGL/GLX/Vulkan runtime (Mesa + ICD loader + tools)"
+      echo " - Qt6 base + declarative + tools + Quick Controls 2 (or Qt5 fallback)"
       exit 1
     fi
   fi
 
   check_tool_versions
-  check_qt_runtime
+
+  case "$pm" in
+    apt)    install_runtime_apt ;;
+    pacman) install_runtime_pacman ;;
+    *)      err "Internal error: unknown package manager '$pm'"; exit 1 ;;
+  esac
 
   echo
   ok "All required dependencies are present (or have been installed)."

+ 44 - 99
ui/qml/GameView.qml

@@ -94,6 +94,18 @@ Item {
             id: mouseArea
             anchors.fill: parent
             acceptedButtons: Qt.LeftButton | Qt.RightButton
+            hoverEnabled: true
+            propagateComposedEvents: true
+            onWheel: function(w) {
+                // Mouse wheel: move camera up/down (RTS-style height adjust)
+                // delta is in eighths of a degree; use angleDelta.y where available
+                var dy = (w.angleDelta ? w.angleDelta.y / 120 : w.delta / 120)
+                if (dy !== 0 && typeof game !== 'undefined' && game.cameraElevate) {
+                    // Scale to world units
+                    game.cameraElevate(-dy * 0.5)
+                }
+                w.accepted = true
+            }
             
             property bool isSelecting: false
             property real startX: 0
@@ -109,6 +121,10 @@ Item {
                     selectionBox.width = 0
                     selectionBox.height = 0
                     selectionBox.visible = true
+                } else if (mouse.button === Qt.RightButton) {
+                    if (typeof game !== 'undefined' && game.onRightClick) {
+                        game.onRightClick(mouse.x, mouse.y)
+                    }
                 }
             }
             
@@ -134,11 +150,17 @@ Item {
                         areaSelected(selectionBox.x, selectionBox.y, 
                                    selectionBox.x + selectionBox.width,
                                    selectionBox.y + selectionBox.height)
+                        if (typeof game !== 'undefined' && game.onAreaSelected) {
+                            game.onAreaSelected(selectionBox.x, selectionBox.y,
+                                                selectionBox.x + selectionBox.width,
+                                                selectionBox.y + selectionBox.height,
+                                                false)
+                        }
                     } else {
                         // Point selection
                         mapClicked(mouse.x, mouse.y)
-                        if (typeof game !== 'undefined' && game.onMapClicked) {
-                            game.onMapClicked(mouse.x, mouse.y)
+                        if (typeof game !== 'undefined' && game.onClickSelect) {
+                            game.onClickSelect(mouse.x, mouse.y, false)
                         }
                     }
                 }
@@ -155,107 +177,30 @@ Item {
         }
     }
     
-    // Edge scrolling areas
-    MouseArea {
-        id: leftEdge
-        anchors.left: parent.left
-        anchors.top: parent.top
-        anchors.bottom: parent.bottom
-        width: 10
-        hoverEnabled: true
-        
-        onEntered: {
-            scrollTimer.direction = "left"
-            scrollTimer.start()
-        }
-        onExited: scrollTimer.stop()
-    }
-    
-    MouseArea {
-        id: rightEdge
-        anchors.right: parent.right
-        anchors.top: parent.top
-        anchors.bottom: parent.bottom
-        width: 10
-        hoverEnabled: true
-        
-        onEntered: {
-            scrollTimer.direction = "right"
-            scrollTimer.start()
-        }
-        onExited: scrollTimer.stop()
-    }
-    
-    MouseArea {
-        id: topEdge
-        anchors.top: parent.top
-        anchors.left: parent.left
-        anchors.right: parent.right
-        height: 10
-        hoverEnabled: true
-        
-        onEntered: {
-            scrollTimer.direction = "up"
-            scrollTimer.start()
-        }
-        onExited: scrollTimer.stop()
-    }
-    
-    MouseArea {
-        id: bottomEdge
-        anchors.bottom: parent.bottom
-        anchors.left: parent.left
-        anchors.right: parent.right
-        height: 10
-        hoverEnabled: true
-        
-        onEntered: {
-            scrollTimer.direction = "down"
-            scrollTimer.start()
-        }
-        onExited: scrollTimer.stop()
-    }
-    
-    Timer {
-        id: scrollTimer
-        interval: 16 // ~60 FPS
-        repeat: true
-        
-        property string direction: ""
-        
-        onTriggered: {
-            // Handle camera movement based on direction
-            console.log("Edge scroll:", direction)
-        }
-    }
+    // Edge scrolling handled by Main.qml overlay for smoother behavior and to bypass overlays
     
     // Keyboard handling
     Keys.onPressed: function(event) {
+        if (typeof game === 'undefined') return
+        var yawStep = event.modifiers & Qt.ShiftModifier ? 4 : 2
+        var panStep = 0.6
         switch (event.key) {
-            case Qt.Key_W:
-                console.log("Move camera forward")
-                break
-            case Qt.Key_S:
-                console.log("Move camera backward")
-                break
-            case Qt.Key_A:
-                console.log("Move camera left")
-                break
-            case Qt.Key_D:
-                console.log("Move camera right")
-                break
-            case Qt.Key_Q:
-                console.log("Rotate camera left")
-                break
-            case Qt.Key_E:
-                console.log("Rotate camera right")
-                break
-            case Qt.Key_R:
-                console.log("Move camera up")
-                break
-            case Qt.Key_F:
-                console.log("Move camera down")
-                break
+            // WASD pans the camera just like arrow keys
+            case Qt.Key_W: game.cameraMove(0, panStep);  event.accepted = true; break
+            case Qt.Key_S: game.cameraMove(0, -panStep); event.accepted = true; break
+            case Qt.Key_A: game.cameraMove(-panStep, 0); event.accepted = true; break
+            case Qt.Key_D: game.cameraMove(panStep, 0);  event.accepted = true; break
+            // Arrow keys pan as well
+            case Qt.Key_Up:    game.cameraMove(0, panStep);  event.accepted = true; break
+            case Qt.Key_Down:  game.cameraMove(0, -panStep); event.accepted = true; break
+            case Qt.Key_Left:  game.cameraMove(-panStep, 0); event.accepted = true; break
+            case Qt.Key_Right: game.cameraMove(panStep, 0);  event.accepted = true; break
+            // Yaw rotation on Q/E
+            case Qt.Key_Q: game.cameraYaw(-yawStep); event.accepted = true; break
+            case Qt.Key_E: game.cameraYaw(yawStep);  event.accepted = true; break
+            // Elevation
+            case Qt.Key_R: game.cameraElevate(0.5);  event.accepted = true; break
+            case Qt.Key_F: game.cameraElevate(-0.5); event.accepted = true; break
         }
     }
     

+ 23 - 8
ui/qml/HUD.qml

@@ -123,6 +123,18 @@ Item {
         color: "#2c3e50"
         border.color: "#34495e"
         border.width: 1
+        // Allow edge-scroll MouseArea under it to still receive hover at the very edge
+        // by adding a tiny pass-through strip
+        MouseArea {
+            anchors.left: parent.left
+            anchors.right: parent.right
+            anchors.bottom: parent.bottom
+            height: 8
+            hoverEnabled: true
+            acceptedButtons: Qt.NoButton
+            propagateComposedEvents: true
+            onEntered: { if (typeof game !== 'undefined') {/* noop - GameView bottomEdge handles scroll */} }
+        }
         
         RowLayout {
             anchors.fill: parent
@@ -152,13 +164,14 @@ Item {
                     ScrollView {
                         width: parent.width
                         height: parent.height - 20
+                        clip: true
+                        ScrollBar.vertical.policy: ScrollBar.AlwaysOn
                         
                         ListView {
                             id: selectedUnitsList
-                            model: ListModel {
-                                ListElement { name: "Warrior"; health: 100; maxHealth: 100 }
-                                ListElement { name: "Archer"; health: 75; maxHealth: 80 }
-                            }
+                            model: (typeof game !== 'undefined' && game.selectedUnitsModel) ? game.selectedUnitsModel : null
+                            boundsBehavior: Flickable.StopAtBounds
+                            flickableDirection: Flickable.VerticalFlick
                             
                             delegate: Rectangle {
                                 width: selectedUnitsList.width
@@ -172,7 +185,7 @@ Item {
                                     spacing: 4
                                     
                                     Text {
-                                        text: model.name
+                                        text: (typeof name !== 'undefined') ? name : "Unit"
                                         color: "white"
                                         font.pointSize: 9
                                     }
@@ -183,7 +196,7 @@ Item {
                                         color: "#e74c3c"
                                         
                                         Rectangle {
-                                            width: parent.width * (model.health / model.maxHealth)
+                                            width: parent.width * (typeof healthRatio !== 'undefined' ? healthRatio : 0)
                                             height: parent.height
                                             color: "#27ae60"
                                         }
@@ -227,8 +240,10 @@ Item {
                 Button {
                     Layout.fillWidth: true
                     Layout.fillHeight: true
-                    text: "Hold"
-                    onClicked: unitCommand("hold")
+                    text: "Follow"
+                    focusPolicy: Qt.NoFocus
+                    checkable: true
+                    onToggled: { if (typeof game !== 'undefined' && game.cameraFollowSelection) game.cameraFollowSelection(checked) }
                 }
                 
                 Button {

+ 68 - 0
ui/qml/Main.qml

@@ -16,6 +16,7 @@ ApplicationWindow {
         id: gameViewItem
         anchors.fill: parent
         z: 0
+        focus: true
     }
     
     // HUD overlay
@@ -23,6 +24,8 @@ ApplicationWindow {
         id: hud
         anchors.fill: parent
         z: 1
+        // Keep keyboard focus on the game view when interacting with HUD controls
+        onActiveFocusChanged: if (activeFocus) gameViewItem.forceActiveFocus()
         
         onPauseToggled: {
             // Handle pause/resume
@@ -37,4 +40,69 @@ ApplicationWindow {
             gameViewItem.issueCommand(command)
         }
     }
+
+    // Edge scroll overlay (hover-only) above HUD to ensure bottom edge works
+    Item {
+        id: edgeScrollOverlay
+        anchors.fill: parent
+        z: 2
+        property real threshold: 80    // px from edge where scrolling starts
+        property real maxSpeed: 1.2    // world units per tick at the very edge
+        property real xPos: -1
+        property real yPos: -1
+
+        // Hover tracker that does not consume clicks
+        MouseArea {
+            anchors.fill: parent
+            hoverEnabled: true
+            acceptedButtons: Qt.NoButton
+            propagateComposedEvents: true
+            preventStealing: false
+            onPositionChanged: function(mouse) {
+                edgeScrollOverlay.xPos = mouse.x
+                edgeScrollOverlay.yPos = mouse.y
+            }
+            onEntered: function(mouse) {
+                edgeScrollTimer.start()
+            }
+            onExited: function(mouse) {
+                edgeScrollTimer.stop()
+                edgeScrollOverlay.xPos = -1
+                edgeScrollOverlay.yPos = -1
+            }
+        }
+
+        Timer {
+            id: edgeScrollTimer
+            interval: 16
+            repeat: true
+            onTriggered: {
+                if (typeof game === 'undefined') return
+                const w = edgeScrollOverlay.width
+                const h = edgeScrollOverlay.height
+                const x = edgeScrollOverlay.xPos
+                const y = edgeScrollOverlay.yPos
+                if (x < 0 || y < 0) return
+                const t = edgeScrollOverlay.threshold
+                const clamp = function(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) }
+                // Distance from edges
+                const dl = x
+                const dr = w - x
+                const dt = y
+                const db = h - y
+                // Normalized intensities (0..1)
+                const il = clamp(1.0 - dl / t, 0, 1)
+                const ir = clamp(1.0 - dr / t, 0, 1)
+                const iu = clamp(1.0 - dt / t, 0, 1)
+                const id = clamp(1.0 - db / t, 0, 1)
+                if (il===0 && ir===0 && iu===0 && id===0) return
+                // Apply gentle curve for smoother start
+                const curve = function(a) { return a*a }
+                const maxS = edgeScrollOverlay.maxSpeed
+                const dx = (curve(ir) - curve(il)) * maxS
+                const dz = (curve(iu) - curve(id)) * maxS
+                if (dx !== 0 || dz !== 0) game.cameraMove(dx, dz)
+            }
+        }
+    }
 }