Browse Source

improve HUD functionalities and UI

djeada 2 months ago
parent
commit
25a027631b

+ 49 - 0
app/game_engine.cpp

@@ -393,6 +393,11 @@ void GameEngine::update(float dt) {
   } else {
     dt *= m_runtime.timeScale;
   }
+
+  if (m_renderer) {
+    m_renderer->updateAnimationTime(dt);
+  }
+
   if (m_world)
     m_world->update(dt);
   syncSelectionFlags();
@@ -628,6 +633,50 @@ QVariantMap GameEngine::getSelectedProductionState() const {
   return m;
 }
 
+QString GameEngine::getSelectedUnitsCommandMode() const {
+  if (!m_selectionSystem || !m_world)
+    return "normal";
+
+  const auto &sel = m_selectionSystem->getSelectedUnits();
+  if (sel.empty())
+    return "normal";
+
+  int attackingCount = 0;
+  int patrollingCount = 0;
+  int totalUnits = 0;
+
+  for (auto id : sel) {
+    auto *e = m_world->getEntity(id);
+    if (!e)
+      continue;
+
+    auto *u = e->getComponent<Engine::Core::UnitComponent>();
+    if (!u)
+      continue;
+    if (u->unitType == "barracks")
+      continue;
+
+    totalUnits++;
+
+    if (e->getComponent<Engine::Core::AttackTargetComponent>())
+      attackingCount++;
+
+    auto *patrol = e->getComponent<Engine::Core::PatrolComponent>();
+    if (patrol && patrol->patrolling)
+      patrollingCount++;
+  }
+
+  if (totalUnits == 0)
+    return "normal";
+
+  if (patrollingCount == totalUnits)
+    return "patrol";
+  if (attackingCount == totalUnits)
+    return "attack";
+
+  return "normal";
+}
+
 void GameEngine::setRallyAtScreen(qreal sx, qreal sy) {
   ensureInitialized();
   if (!m_world || !m_selectionSystem)

+ 1 - 0
app/game_engine.h

@@ -98,6 +98,7 @@ public:
   Q_INVOKABLE bool hasSelectedType(const QString &type) const;
   Q_INVOKABLE void recruitNearSelected(const QString &unitType);
   Q_INVOKABLE QVariantMap getSelectedProductionState() const;
+  Q_INVOKABLE QString getSelectedUnitsCommandMode() const;
   Q_INVOKABLE void setRallyAtScreen(qreal sx, qreal sy);
   Q_INVOKABLE QVariantList availableMaps() const;
   Q_INVOKABLE void startSkirmish(const QString &mapPath);

+ 7 - 6
assets/maps/test_map.json

@@ -12,8 +12,8 @@
     "distance": 15.0,
     "tiltDeg": 45.0,
     "fovY": 45.0,
-    "near": 0.1,
-    "far": 1000.0
+    "near": 1.0,
+    "far": 200.0
   },
   "spawns": [
     { "type": "barracks", "x": 45, "z": 50, "playerId": 1 },
@@ -22,11 +22,12 @@
     { "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": 48, "z": 48, "playerId": 1 },
+    { "type": "archer", "x": 52, "z": 48, "playerId": 1 },
+    { "type": "archer", "x": 50, "z": 48, "playerId": 1 },
+    { "type": "archer", "x": 48, "z": 48, "playerId": 1 },
     
     { "type": "barracks", "x": 65, "z": 50, "playerId": 2 },
-    { "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 }
+    { "type": "archer", "x": 56, "z": 50, "playerId": 2 }
   ]
 }

+ 3 - 0
game/core/component.h

@@ -21,6 +21,9 @@ public:
   Vec3 position;
   Vec3 rotation;
   Vec3 scale;
+
+  float desiredYaw = 0.0f;
+  bool hasDesiredYaw = false;
 };
 
 class RenderableComponent : public Component {

+ 2 - 1
game/map/environment.cpp

@@ -22,7 +22,8 @@ void Environment::apply(const MapDefinition &def,
 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);
+
+  camera.setPerspective(45.0f, 16.0f / 9.0f, 1.0f, 200.0f);
   Render::GL::Renderer::GridParams gp;
   gp.cellSize = 1.0f;
   gp.extent = 50.0f;

+ 3 - 2
game/map/map_definition.h

@@ -17,8 +17,9 @@ struct CameraDefinition {
   float distance = 15.0f;
   float tiltDeg = 45.0f;
   float fovY = 45.0f;
-  float nearPlane = 0.1f;
-  float farPlane = 1000.0f;
+
+  float nearPlane = 1.0f;
+  float farPlane = 200.0f;
 };
 
 struct UnitSpawn {

+ 46 - 0
game/systems/combat_system.cpp

@@ -69,6 +69,20 @@ void CombatSystem::processAttacks(Engine::Core::World *world, float deltaTime) {
 
           if (isInRange(attacker, target, range)) {
             bestTarget = target;
+
+            if (auto *attT =
+                    attacker
+                        ->getComponent<Engine::Core::TransformComponent>()) {
+              if (auto *tgtT =
+                      target
+                          ->getComponent<Engine::Core::TransformComponent>()) {
+                float dx = tgtT->position.x - attT->position.x;
+                float dz = tgtT->position.z - attT->position.z;
+                float yaw = std::atan2(dx, dz) * 180.0f / 3.14159265f;
+                attT->desiredYaw = yaw;
+                attT->hasDesiredYaw = true;
+              }
+            }
           } else if (attackTarget->shouldChase) {
 
             auto *movement =
@@ -135,6 +149,32 @@ void CombatSystem::processAttacks(Engine::Core::World *world, float deltaTime) {
       auto *bestTargetUnit =
           bestTarget->getComponent<Engine::Core::UnitComponent>();
 
+      if (!attacker->hasComponent<Engine::Core::AttackTargetComponent>()) {
+        auto *newTarget =
+            attacker->addComponent<Engine::Core::AttackTargetComponent>();
+        newTarget->targetId = bestTarget->getId();
+        newTarget->shouldChase = false;
+      } else {
+        auto *existingTarget =
+            attacker->getComponent<Engine::Core::AttackTargetComponent>();
+        if (existingTarget->targetId != bestTarget->getId()) {
+          existingTarget->targetId = bestTarget->getId();
+          existingTarget->shouldChase = false;
+        }
+      }
+
+      if (auto *attT =
+              attacker->getComponent<Engine::Core::TransformComponent>()) {
+        if (auto *tgtT =
+                bestTarget->getComponent<Engine::Core::TransformComponent>()) {
+          float dx = tgtT->position.x - attT->position.x;
+          float dz = tgtT->position.z - attT->position.z;
+          float yaw = std::atan2(dx, dz) * 180.0f / 3.14159265f;
+          attT->desiredYaw = yaw;
+          attT->hasDesiredYaw = true;
+        }
+      }
+
       if (arrowSys) {
         auto attT = attacker->getComponent<Engine::Core::TransformComponent>();
         auto tgtT =
@@ -152,6 +192,12 @@ void CombatSystem::processAttacks(Engine::Core::World *world, float deltaTime) {
       }
       dealDamage(bestTarget, damage);
       *tAccum = 0.0f;
+    } else {
+
+      if (!attackTarget &&
+          attacker->hasComponent<Engine::Core::AttackTargetComponent>()) {
+        attacker->removeComponent<Engine::Core::AttackTargetComponent>();
+      }
     }
   }
 }

+ 13 - 0
game/systems/movement_system.cpp

@@ -79,6 +79,19 @@ void MovementSystem::moveUnit(Engine::Core::Entity *entity, float deltaTime) {
       float step =
           std::clamp(diff, -turnSpeed * deltaTime, turnSpeed * deltaTime);
       transform->rotation.y = current + step;
+    } else if (transform->hasDesiredYaw) {
+
+      float current = transform->rotation.y;
+      float targetYaw = transform->desiredYaw;
+      float diff = std::fmod((targetYaw - current + 540.0f), 360.0f) - 180.0f;
+      float turnSpeed = 180.0f;
+      float step =
+          std::clamp(diff, -turnSpeed * deltaTime, turnSpeed * deltaTime);
+      transform->rotation.y = current + step;
+
+      if (std::fabs(diff) < 0.5f) {
+        transform->hasDesiredYaw = false;
+      }
     }
   }
 }

+ 2 - 0
render/CMakeLists.txt

@@ -3,6 +3,7 @@ add_library(render_gl STATIC
     gl/buffer.cpp
     gl/mesh.cpp
     gl/texture.cpp
+    gl/primitives.cpp
     scene_renderer.cpp
     gl/camera.cpp
     gl/resources.cpp
@@ -21,6 +22,7 @@ add_library(render_gl STATIC
     geom/arrow.cpp
     geom/flag.cpp
     geom/patrol_flags.cpp
+    geom/transforms.cpp
 )
 
 target_include_directories(render_gl PUBLIC .)

+ 470 - 411
render/entity/archer_renderer.cpp

@@ -1,318 +1,420 @@
 #include "archer_renderer.h"
 #include "../../game/core/component.h"
 #include "../../game/core/entity.h"
+#include "../../game/core/world.h"
 #include "../../game/visuals/team_colors.h"
+#include "../geom/math_utils.h"
 #include "../geom/selection_ring.h"
+#include "../geom/transforms.h"
 #include "../gl/mesh.h"
+#include "../gl/primitives.h"
 #include "../gl/texture.h"
 #include "registry.h"
+
 #include <QMatrix4x4>
 #include <QVector3D>
-#include <algorithm>
 #include <cmath>
-#include <memory>
-#include <vector>
+#include <cstdint>
 
 namespace Render::GL {
 
-static Mesh *createUnitCylinderMesh() {
-  const int radial = 24;
-  const float radius = 1.0f;
-  const float halfH = 0.5f;
-  std::vector<Vertex> v;
-  std::vector<unsigned int> idx;
-
-  for (int y = 0; y <= 1; ++y) {
-    float py = y ? halfH : -halfH;
-    float vCoord = float(y);
-    for (int i = 0; i <= radial; ++i) {
-      float u = float(i) / float(radial);
-      float ang = u * 6.28318530718f;
-      float px = radius * std::cos(ang);
-      float pz = radius * std::sin(ang);
-      QVector3D n(px, 0.0f, pz);
-      n.normalize();
-      v.push_back({{px, py, pz}, {n.x(), n.y(), n.z()}, {u, vCoord}});
+using Render::Geom::clamp01;
+using Render::Geom::clampf;
+using Render::Geom::clampVec01;
+using Render::Geom::coneFromTo;
+using Render::Geom::cylinderBetween;
+using Render::Geom::sphereAt;
+
+struct HumanProportions {
+
+  static constexpr float TOTAL_HEIGHT = 2.00f;
+  static constexpr float HEAD_HEIGHT = 0.25f;
+
+  static constexpr float GROUND_Y = 0.0f;
+  static constexpr float HEAD_TOP_Y = GROUND_Y + TOTAL_HEIGHT;
+  static constexpr float CHIN_Y = HEAD_TOP_Y - HEAD_HEIGHT;
+  static constexpr float NECK_BASE_Y = CHIN_Y - 0.10f;
+  static constexpr float SHOULDER_Y = NECK_BASE_Y - 0.15f;
+  static constexpr float CHEST_Y = SHOULDER_Y - 0.35f;
+  static constexpr float WAIST_Y = CHEST_Y - 0.30f;
+  static constexpr float HIP_Y = WAIST_Y - 0.15f;
+  static constexpr float KNEE_Y = HIP_Y - 0.35f;
+
+  static constexpr float SHOULDER_WIDTH = HEAD_HEIGHT * 1.6f;
+  static constexpr float HEAD_RADIUS = HEAD_HEIGHT * 0.40f;
+  static constexpr float NECK_RADIUS = HEAD_RADIUS * 0.35f;
+  static constexpr float TORSO_TOP_R = HEAD_RADIUS * 1.0f;
+  static constexpr float TORSO_BOT_R = HEAD_RADIUS * 0.9f;
+  static constexpr float UPPER_ARM_R = HEAD_RADIUS * 0.30f;
+  static constexpr float FORE_ARM_R = HEAD_RADIUS * 0.25f;
+  static constexpr float HAND_RADIUS = HEAD_RADIUS * 0.22f;
+  static constexpr float UPPER_LEG_R = HEAD_RADIUS * 0.38f;
+  static constexpr float LOWER_LEG_R = HEAD_RADIUS * 0.32f;
+
+  static constexpr float UPPER_ARM_LEN = 0.28f;
+  static constexpr float FORE_ARM_LEN = 0.30f;
+  static constexpr float UPPER_LEG_LEN = 0.35f;
+  static constexpr float LOWER_LEG_LEN = 0.35f;
+};
+
+struct ArcherColors {
+  QVector3D tunic, skin, leather, leatherDark, wood, metal, metalHead,
+      stringCol, fletch;
+};
+
+struct ArcherPose {
+  using P = HumanProportions;
+
+  QVector3D headPos{0.0f, (P::HEAD_TOP_Y + P::CHIN_Y) * 0.5f, 0.0f};
+  float headR = P::HEAD_RADIUS;
+  QVector3D neckBase{0.0f, P::NECK_BASE_Y, 0.0f};
+
+  QVector3D shoulderL{-P::SHOULDER_WIDTH * 0.5f, P::SHOULDER_Y, 0.1f};
+  QVector3D shoulderR{P::SHOULDER_WIDTH * 0.5f, P::SHOULDER_Y, 0.1f};
+
+  QVector3D elbowL, elbowR;
+  QVector3D handL, handR;
+
+  float hipSpacing = P::SHOULDER_WIDTH * 0.55f;
+
+  float hipXFactor = 0.45f;
+  float hipZOffset = 0.01f;
+  QVector3D hipL{-hipSpacing * hipXFactor,
+                 std::max(P::HIP_Y + 0.05f, P::GROUND_Y + 0.3f), hipZOffset};
+  QVector3D hipR{hipSpacing * hipXFactor,
+                 std::max(P::HIP_Y + 0.05f, P::GROUND_Y + 0.3f), -hipZOffset};
+
+  float footYOffset = 0.02f;
+  QVector3D footL{-hipSpacing * 1.05f, P::GROUND_Y + footYOffset, 0.18f};
+  QVector3D footR{hipSpacing * 1.05f, P::GROUND_Y + footYOffset, -0.14f};
+  float bowX = 0.0f;
+  float bowTopY = P::SHOULDER_Y + 0.55f;
+  float bowBotY = P::HIP_Y - 0.25f;
+  float bowRodR = 0.035f;
+  float stringR = 0.008f;
+  float bowDepth = 0.25f;
+};
+
+static inline ArcherPose makePose(uint32_t seed, float animTime, bool isMoving,
+                                  bool isAttacking) {
+  (void)seed;
+  ArcherPose P;
+
+  using HP = HumanProportions;
+
+  P.handL = QVector3D(P.bowX - 0.05f, HP::SHOULDER_Y + 0.05f, 0.55f);
+  P.handR = QVector3D(0.15f, HP::SHOULDER_Y + 0.15f, 0.20f);
+
+  if (isAttacking) {
+    float attackCycleTime = 1.2f;
+    float attackPhase = fmod(animTime * (1.0f / attackCycleTime), 1.0f);
+
+    QVector3D restPos(0.15f, HP::SHOULDER_Y + 0.15f, 0.20f);
+    QVector3D drawPos(0.35f, HP::SHOULDER_Y + 0.08f, -0.15f);
+
+    if (attackPhase < 0.3f) {
+
+      float t = attackPhase / 0.3f;
+      t = t * t;
+      P.handR = restPos * (1.0f - t) + drawPos * t;
+    } else if (attackPhase < 0.6f) {
+
+      P.handR = drawPos;
+    } else {
+
+      float t = (attackPhase - 0.6f) / 0.4f;
+      t = 1.0f - (1.0f - t) * (1.0f - t);
+      P.handR = drawPos * (1.0f - t) + restPos * t;
     }
   }
-  int row = radial + 1;
-
-  for (int i = 0; i < radial; ++i) {
-    int a = 0 * row + i;
-    int b = 0 * row + i + 1;
-    int c = 1 * row + i + 1;
-    int d = 1 * row + 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);
-  }
 
-  int baseTop = (int)v.size();
-  v.push_back({{0.0f, halfH, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.5f, 0.5f}});
-  for (int i = 0; i <= radial; ++i) {
-    float u = float(i) / float(radial);
-    float ang = u * 6.28318530718f;
-    float px = radius * std::cos(ang);
-    float pz = radius * std::sin(ang);
-    v.push_back({{px, halfH, pz},
-                 {0.0f, 1.0f, 0.0f},
-                 {0.5f + 0.5f * std::cos(ang), 0.5f + 0.5f * std::sin(ang)}});
-  }
-  for (int i = 1; i <= radial; ++i) {
-    idx.push_back(baseTop);
-    idx.push_back(baseTop + i);
-    idx.push_back(baseTop + i + 1);
-  }
+  if (isMoving) {
+    float walkCycleTime = 0.8f;
+    float walkPhase = fmod(animTime * (1.0f / walkCycleTime), 1.0f);
+    float leftPhase = walkPhase;
+    float rightPhase = fmod(walkPhase + 0.5f, 1.0f);
 
-  int baseBot = (int)v.size();
-  v.push_back({{0.0f, -halfH, 0.0f}, {0.0f, -1.0f, 0.0f}, {0.5f, 0.5f}});
-  for (int i = 0; i <= radial; ++i) {
-    float u = float(i) / float(radial);
-    float ang = u * 6.28318530718f;
-    float px = radius * std::cos(ang);
-    float pz = radius * std::sin(ang);
-    v.push_back({{px, -halfH, pz},
-                 {0.0f, -1.0f, 0.0f},
-                 {0.5f + 0.5f * std::cos(ang), 0.5f + 0.5f * std::sin(ang)}});
-  }
-  for (int i = 1; i <= radial; ++i) {
-    idx.push_back(baseBot);
-    idx.push_back(baseBot + i + 1);
-    idx.push_back(baseBot + i);
-  }
-  return new Mesh(v, idx);
-}
+    const float footYOffset = P.footYOffset;
+    const float groundY = HP::GROUND_Y;
 
-static Mesh *createUnitSphereMesh() {
-  const int lat = 12;
-  const int lon = 24;
-  const float r = 1.0f;
-  std::vector<Vertex> v;
-  std::vector<unsigned int> idx;
-
-  for (int y = 0; y <= lat; ++y) {
-    float vy = float(y) / float(lat);
-    float phi = vy * 3.1415926535f;
-    float py = r * std::cos(phi - 1.57079632679f);
-    float pr = r * std::sin(phi);
-    for (int x = 0; x <= lon; ++x) {
-      float vx = float(x) / float(lon);
-      float theta = vx * 6.28318530718f;
-      float px = pr * std::cos(theta);
-      float pz = pr * std::sin(theta);
-      QVector3D n(px, py, pz);
-      n.normalize();
-      v.push_back({{px, py, pz}, {n.x(), n.y(), n.z()}, {vx, vy}});
-    }
-  }
-  int row = lon + 1;
-  for (int y = 0; y < lat; ++y) {
-    for (int x = 0; x < lon; ++x) {
-      int a = y * row + x;
-      int b = a + 1;
-      int c = (y + 1) * row + x + 1;
-      int d = (y + 1) * row + x;
-      idx.push_back(a);
-      idx.push_back(b);
-      idx.push_back(c);
-      idx.push_back(c);
-      idx.push_back(d);
-      idx.push_back(a);
-    }
-  }
-  return new Mesh(v, idx);
-}
+    auto animateFoot = [groundY, footYOffset](QVector3D &foot, float phase) {
+      float lift = std::sin(phase * 2.0f * 3.14159f);
+      if (lift > 0.0f) {
+        foot.setY(groundY + footYOffset + lift * 0.15f);
+      }
 
-static Mesh *createUnitConeMesh() {
-  const int radial = 24;
-  const float baseR = 1.0f;
-  const float halfH = 0.5f;
-  std::vector<Vertex> v;
-  std::vector<unsigned int> idx;
-
-  int apexIdx = 0;
-  v.push_back({{0.0f, +halfH, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.5f, 1.0f}});
-
-  for (int i = 0; i <= radial; ++i) {
-    float u = float(i) / float(radial);
-    float ang = u * 6.28318530718f;
-    float px = baseR * std::cos(ang);
-    float pz = baseR * std::sin(ang);
-
-    QVector3D n(px, baseR, pz);
-    n.normalize();
-    v.push_back({{px, -halfH, pz}, {n.x(), n.y(), n.z()}, {u, 0.0f}});
-  }
+      foot.setZ(foot.z() + std::sin((phase - 0.25f) * 2.0f * 3.14159f) * 0.20f);
+    };
 
-  for (int i = 1; i <= radial; ++i) {
-    int a = apexIdx;
-    int b = i;
-    int c = i + 1;
-    idx.push_back(a);
-    idx.push_back(b);
-    idx.push_back(c);
+    animateFoot(P.footL, leftPhase);
+    animateFoot(P.footR, rightPhase);
   }
 
-  int baseCenter = (int)v.size();
-  v.push_back({{0.0f, -halfH, 0.0f}, {0.0f, -1.0f, 0.0f}, {0.5f, 0.5f}});
-
-  int baseStart = (int)v.size();
-  for (int i = 0; i <= radial; ++i) {
-    float u = float(i) / float(radial);
-    float ang = u * 6.28318530718f;
-    float px = baseR * std::cos(ang);
-    float pz = baseR * std::sin(ang);
-    v.push_back({{px, -halfH, pz},
-                 {0.0f, -1.0f, 0.0f},
-                 {0.5f + 0.5f * std::cos(ang), 0.5f + 0.5f * std::sin(ang)}});
-  }
-  for (int i = 0; i < radial; ++i) {
-    idx.push_back(baseCenter);
-    idx.push_back(baseStart + i + 1);
-    idx.push_back(baseStart + i);
-  }
-  return new Mesh(v, idx);
-}
+  QVector3D shoulderToHandL = P.handL - P.shoulderL;
+  float distL = shoulderToHandL.length();
+  QVector3D dirL = shoulderToHandL.normalized();
 
-static Mesh *createCapsuleMesh() {
-  const int radial = 24;
-  const int heightSegments = 1;
-  const float radius = 0.25f;
-  const float halfH = 0.5f;
-  std::vector<Vertex> verts;
-  std::vector<unsigned int> idx;
-
-  for (int y = 0; y <= heightSegments; ++y) {
-    float v = float(y) / float(heightSegments);
-    float py = -halfH + v * (2.0f * halfH);
-    for (int i = 0; i <= radial; ++i) {
-      float u = float(i) / float(radial);
-      float ang = u * 6.2831853f;
-      float px = radius * std::cos(ang);
-      float pz = radius * std::sin(ang);
-      QVector3D n(px, 0.0f, pz);
-      n.normalize();
-      verts.push_back({{px, py, pz}, {n.x(), n.y(), n.z()}, {u, v}});
-    }
-  }
-  int row = radial + 1;
-  for (int y = 0; y < heightSegments; ++y) {
-    for (int i = 0; i < radial; ++i) {
-      int a = y * row + i;
-      int b = y * row + i + 1;
-      int c = (y + 1) * row + i + 1;
-      int d = (y + 1) * row + 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);
-    }
-  }
+  QVector3D perpL(-dirL.z(), 0.0f, dirL.x());
+  float elbowOffsetL = 0.15f;
+  P.elbowL = P.shoulderL + dirL * (distL * 0.45f) + perpL * elbowOffsetL +
+             QVector3D(0, -0.08f, 0);
 
-  int baseTop = (int)verts.size();
-  verts.push_back({{0.0f, halfH, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.5f, 0.5f}});
-  for (int i = 0; i <= radial; ++i) {
-    float u = float(i) / float(radial);
-    float ang = u * 6.2831853f;
-    float px = radius * std::cos(ang);
-    float pz = radius * std::sin(ang);
-    verts.push_back(
-        {{px, halfH, pz},
-         {0.0f, 1.0f, 0.0f},
-         {0.5f + 0.5f * std::cos(ang), 0.5f + 0.5f * std::sin(ang)}});
-  }
-  for (int i = 1; i <= radial; ++i) {
-    idx.push_back(baseTop);
-    idx.push_back(baseTop + i);
-    idx.push_back(baseTop + i + 1);
-  }
+  QVector3D shoulderToHandR = P.handR - P.shoulderR;
+  float distR = shoulderToHandR.length();
+  QVector3D dirR = shoulderToHandR.normalized();
 
-  int baseBot = (int)verts.size();
-  verts.push_back({{0.0f, -halfH, 0.0f}, {0.0f, -1.0f, 0.0f}, {0.5f, 0.5f}});
-  for (int i = 0; i <= radial; ++i) {
-    float u = float(i) / float(radial);
-    float ang = u * 6.2831853f;
-    float px = radius * std::cos(ang);
-    float pz = radius * std::sin(ang);
-    verts.push_back(
-        {{px, -halfH, pz},
-         {0.0f, -1.0f, 0.0f},
-         {0.5f + 0.5f * std::cos(ang), 0.5f + 0.5f * std::sin(ang)}});
-  }
-  for (int i = 1; i <= radial; ++i) {
-    idx.push_back(baseBot);
-    idx.push_back(baseBot + i + 1);
-    idx.push_back(baseBot + i);
-  }
-  return new Mesh(verts, idx);
+  QVector3D perpR(-dirR.z(), 0.0f, dirR.x());
+  float elbowOffsetR = 0.12f;
+  P.elbowR = P.shoulderR + dirR * (distR * 0.48f) + perpR * elbowOffsetR +
+             QVector3D(0, 0.02f, 0);
+
+  return P;
 }
 
-static Mesh *getUnitCylinder() {
-  static std::unique_ptr<Mesh> m(createUnitCylinderMesh());
-  return m.get();
+static inline ArcherColors makeColors(const QVector3D &teamTint) {
+  ArcherColors C;
+  auto tint = [&](float k) {
+    return QVector3D(clamp01(teamTint.x() * k), clamp01(teamTint.y() * k),
+                     clamp01(teamTint.z() * k));
+  };
+  C.tunic = teamTint;
+  C.skin = QVector3D(0.96f, 0.80f, 0.69f);
+  C.leather = QVector3D(0.35f, 0.22f, 0.12f);
+  C.leatherDark = C.leather * 0.9f;
+  C.wood = QVector3D(0.16f, 0.10f, 0.05f);
+  C.metal = QVector3D(0.65f, 0.66f, 0.70f);
+  C.metalHead = clampVec01(C.metal * 1.1f);
+  C.stringCol = QVector3D(0.30f, 0.30f, 0.32f);
+  C.fletch = tint(0.9f);
+  return C;
 }
-static Mesh *getUnitSphere() {
-  static std::unique_ptr<Mesh> m(createUnitSphereMesh());
-  return m.get();
+
+static inline void drawTorso(const DrawContext &p, ISubmitter &out,
+                             const ArcherColors &C, const ArcherPose &P) {
+  using HP = HumanProportions;
+
+  QVector3D torsoTop{0.0f, HP::NECK_BASE_Y - 0.05f, 0.0f};
+  QVector3D torsoBot{0.0f, HP::WAIST_Y, 0.0f};
+
+  float torsoRadius = HP::TORSO_TOP_R;
+
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(torsoTop, torsoBot, torsoRadius), C.tunic,
+           nullptr, 1.0f);
+
+  QVector3D waist{0.0f, HP::WAIST_Y, 0.0f};
+  QVector3D hipCenter = (P.hipL + P.hipR) * 0.5f;
+
+  out.mesh(getUnitCone(),
+           p.model * coneFromTo(waist, hipCenter, HP::TORSO_BOT_R),
+           C.tunic * 0.9f, nullptr, 1.0f);
 }
-static Mesh *getUnitCone() {
-  static std::unique_ptr<Mesh> m(createUnitConeMesh());
-  return m.get();
+
+static inline void drawHeadAndNeck(const DrawContext &p, ISubmitter &out,
+                                   const ArcherPose &P, const ArcherColors &C) {
+  using HP = HumanProportions;
+
+  QVector3D chinPos{0.0f, HP::CHIN_Y, 0.0f};
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.neckBase, chinPos, HP::NECK_RADIUS),
+           C.skin * 0.9f, nullptr, 1.0f);
+
+  out.mesh(getUnitSphere(), p.model * sphereAt(P.headPos, P.headR), C.skin,
+           nullptr, 1.0f);
+
+  float headTopOffset = P.headR * 0.7f;
+  QVector3D helmBase = P.headPos + QVector3D(0.0f, headTopOffset, 0.0f);
+  QVector3D helmApex = P.headPos + QVector3D(0.0f, P.headR * 2.4f, 0.0f);
+  float helmBaseR = P.headR * 1.45f;
+  out.mesh(getUnitCone(), p.model * coneFromTo(helmBase, helmApex, helmBaseR),
+           C.tunic, nullptr, 1.0f);
+
+  QVector3D iris(0.06f, 0.06f, 0.07f);
+  float eyeZ = P.headR * 0.7f;
+  float eyeY = P.headPos.y() + P.headR * 0.1f;
+  float eyeSpacing = P.headR * 0.35f;
+  out.mesh(getUnitSphere(),
+           p.model *
+               sphereAt(QVector3D(-eyeSpacing, eyeY, eyeZ), P.headR * 0.15f),
+           iris, nullptr, 1.0f);
+  out.mesh(getUnitSphere(),
+           p.model *
+               sphereAt(QVector3D(eyeSpacing, eyeY, eyeZ), P.headR * 0.15f),
+           iris, nullptr, 1.0f);
 }
-static Mesh *getArcherCapsule() {
-  static std::unique_ptr<Mesh> m(createCapsuleMesh());
-  return m.get();
+
+static inline void drawArms(const DrawContext &p, ISubmitter &out,
+                            const ArcherPose &P, const ArcherColors &C) {
+  using HP = HumanProportions;
+
+  const float upperArmR = HP::UPPER_ARM_R;
+  const float foreArmR = HP::FORE_ARM_R;
+  const float jointR = HP::HAND_RADIUS * 1.05f;
+
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.shoulderL, P.elbowL, upperArmR), C.tunic,
+           nullptr, 1.0f);
+  out.mesh(getUnitSphere(), p.model * sphereAt(P.elbowL, jointR),
+           C.tunic * 0.95f, nullptr, 1.0f);
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.elbowL, P.handL, foreArmR),
+           C.skin * 0.95f, nullptr, 1.0f);
+
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.shoulderR, P.elbowR, upperArmR), C.tunic,
+           nullptr, 1.0f);
+  out.mesh(getUnitSphere(), p.model * sphereAt(P.elbowR, jointR),
+           C.tunic * 0.95f, nullptr, 1.0f);
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.elbowR, P.handR, foreArmR),
+           C.skin * 0.95f, nullptr, 1.0f);
 }
 
-static inline float clamp01(float x) {
-  return std::max(0.0f, std::min(1.0f, x));
+static inline void drawLegs(const DrawContext &p, ISubmitter &out,
+                            const ArcherPose &P, const ArcherColors &C) {
+  using HP = HumanProportions;
+
+  QVector3D kneeL = P.hipL + (P.footL - P.hipL) * 0.45f;
+  QVector3D kneeR = P.hipR + (P.footR - P.hipR) * 0.45f;
+  kneeL.setY(HP::KNEE_Y + 0.05f);
+  kneeR.setY(HP::KNEE_Y + 0.05f);
+
+  const float thighR = HP::UPPER_LEG_R;
+  const float shinR = HP::LOWER_LEG_R;
+  const float kneeJointR = thighR * 1.15f;
+
+  out.mesh(getUnitCone(), p.model * coneFromTo(P.hipL, kneeL, thighR),
+           C.leather, nullptr, 1.0f);
+  out.mesh(getUnitCone(), p.model * coneFromTo(P.hipR, kneeR, thighR),
+           C.leather, nullptr, 1.0f);
+
+  out.mesh(getUnitSphere(), p.model * sphereAt(kneeL, kneeJointR),
+           C.leather * 0.95f, nullptr, 1.0f);
+  out.mesh(getUnitSphere(), p.model * sphereAt(kneeR, kneeJointR),
+           C.leather * 0.95f, nullptr, 1.0f);
+
+  out.mesh(getUnitCone(), p.model * coneFromTo(kneeL, P.footL, shinR),
+           C.leatherDark, nullptr, 1.0f);
+  out.mesh(getUnitCone(), p.model * coneFromTo(kneeR, P.footR, shinR),
+           C.leatherDark, nullptr, 1.0f);
+
+  QVector3D down(0.0f, -0.02f, 0.0f);
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.footL, P.footL + down, shinR * 1.1f),
+           C.leatherDark, nullptr, 1.0f);
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.footR, P.footR + down, shinR * 1.1f),
+           C.leatherDark, nullptr, 1.0f);
 }
 
-static QMatrix4x4 cylinderBetween(const QVector3D &a, const QVector3D &b,
-                                  float radius) {
-  QVector3D mid = (a + b) * 0.5f;
-  QVector3D dir = b - a;
-  float len = dir.length();
-  QMatrix4x4 M;
-  M.translate(mid);
-  if (len > 1e-6f) {
-    QVector3D yAxis(0, 1, 0);
-    QVector3D d = dir / len;
-    float dot = std::clamp(QVector3D::dotProduct(yAxis, d), -1.0f, 1.0f);
-    float angleDeg = std::acos(dot) * 57.2957795131f;
-    QVector3D axis = QVector3D::crossProduct(yAxis, d);
-    if (axis.lengthSquared() < 1e-6f) {
-      if (dot < 0.0f)
-        M.rotate(180.0f, 1.0f, 0.0f, 0.0f);
-    } else {
-      axis.normalize();
-      M.rotate(angleDeg, axis);
-    }
-    M.scale(radius, len, radius);
-  } else {
-    M.scale(radius, 1.0f, radius);
-  }
-  return M;
+static inline void drawQuiver(const DrawContext &p, ISubmitter &out,
+                              const ArcherColors &C, const ArcherPose &P,
+                              uint32_t seed) {
+  using HP = HumanProportions;
+
+  auto hash01 = [](uint32_t x) {
+    x ^= x << 13;
+    x ^= x >> 17;
+    x ^= x << 5;
+    return (x & 0x00FFFFFF) / float(0x01000000);
+  };
+
+  QVector3D qTop(-0.08f, HP::SHOULDER_Y + 0.10f, -0.25f);
+  QVector3D qBase(-0.10f, HP::CHEST_Y, -0.22f);
+
+  float quiverR = HP::HEAD_RADIUS * 0.45f;
+  out.mesh(getUnitCylinder(), p.model * cylinderBetween(qBase, qTop, quiverR),
+           C.leather, nullptr, 1.0f);
+
+  float j = (hash01(seed) - 0.5f) * 0.04f;
+  float k = (hash01(seed ^ 0x9E3779B9u) - 0.5f) * 0.04f;
+
+  QVector3D a1 = qTop + QVector3D(0.00f + j, 0.08f, 0.00f + k);
+  out.mesh(getUnitCylinder(), p.model * cylinderBetween(qTop, a1, 0.010f),
+           C.wood, nullptr, 1.0f);
+  out.mesh(getUnitCone(),
+           p.model * coneFromTo(a1, a1 + QVector3D(0, 0.05f, 0), 0.025f),
+           C.fletch, nullptr, 1.0f);
+
+  QVector3D a2 = qTop + QVector3D(0.02f - j, 0.07f, 0.02f - k);
+  out.mesh(getUnitCylinder(), p.model * cylinderBetween(qTop, a2, 0.010f),
+           C.wood, nullptr, 1.0f);
+  out.mesh(getUnitCone(),
+           p.model * coneFromTo(a2, a2 + QVector3D(0, 0.05f, 0), 0.025f),
+           C.fletch, nullptr, 1.0f);
 }
 
-static QMatrix4x4 sphereAt(const QVector3D &pos, float radius) {
-  QMatrix4x4 M;
-  M.translate(pos);
-  M.scale(radius, radius, radius);
-  return M;
+static inline void drawBowAndArrow(const DrawContext &p, ISubmitter &out,
+                                   const ArcherPose &P, const ArcherColors &C) {
+  const QVector3D up(0.0f, 1.0f, 0.0f);
+  const QVector3D forward(0.0f, 0.0f, 1.0f);
+
+  QVector3D grip = P.handL;
+  QVector3D topEnd(P.bowX, P.bowTopY, grip.z());
+  QVector3D botEnd(P.bowX, P.bowBotY, grip.z());
+
+  QVector3D nock(P.bowX,
+                 clampf(P.handR.y(), P.bowBotY + 0.05f, P.bowTopY - 0.05f),
+                 clampf(P.handR.z(), grip.z() - 0.30f, grip.z() + 0.30f));
+
+  const int segs = 22;
+  auto qBezier = [](const QVector3D &a, const QVector3D &c, const QVector3D &b,
+                    float t) {
+    float u = 1.0f - t;
+    return u * u * a + 2.0f * u * t * c + t * t * b;
+  };
+  QVector3D ctrl = nock + forward * P.bowDepth;
+  QVector3D prev = botEnd;
+  for (int i = 1; i <= segs; ++i) {
+    float t = float(i) / float(segs);
+    QVector3D cur = qBezier(botEnd, ctrl, topEnd, t);
+    out.mesh(getUnitCylinder(), p.model * cylinderBetween(prev, cur, P.bowRodR),
+             C.wood, nullptr, 1.0f);
+    prev = cur;
+  }
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(grip - up * 0.05f, grip + up * 0.05f,
+                                     P.bowRodR * 1.45f),
+           C.wood, nullptr, 1.0f);
+
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(topEnd, nock, P.stringR), C.stringCol,
+           nullptr, 1.0f);
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(nock, botEnd, P.stringR), C.stringCol,
+           nullptr, 1.0f);
+  out.mesh(getUnitCylinder(), p.model * cylinderBetween(P.handR, nock, 0.0045f),
+           C.stringCol * 0.9f, nullptr, 1.0f);
+
+  QVector3D tail = nock - forward * 0.06f;
+  QVector3D tip = tail + forward * 0.90f;
+  out.mesh(getUnitCylinder(), p.model * cylinderBetween(tail, tip, 0.018f),
+           C.wood, nullptr, 1.0f);
+  QVector3D headBase = tip - forward * 0.10f;
+  out.mesh(getUnitCone(), p.model * coneFromTo(headBase, tip, 0.05f),
+           C.metalHead, nullptr, 1.0f);
+  QVector3D f1b = tail - forward * 0.02f, f1a = f1b - forward * 0.06f;
+  QVector3D f2b = tail + forward * 0.02f, f2a = f2b + forward * 0.06f;
+  out.mesh(getUnitCone(), p.model * coneFromTo(f1b, f1a, 0.04f), C.fletch,
+           nullptr, 1.0f);
+  out.mesh(getUnitCone(), p.model * coneFromTo(f2a, f2b, 0.04f), C.fletch,
+           nullptr, 1.0f);
 }
 
-static QMatrix4x4 coneFromTo(const QVector3D &baseCenter, const QVector3D &apex,
-                             float baseRadius) {
-  return cylinderBetween(baseCenter, apex, baseRadius);
+static inline void drawSelectionFX(const DrawContext &p, ISubmitter &out) {
+  if (p.selected || p.hovered) {
+    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);
+    if (p.selected)
+      out.selectionRing(ringM, 0.6f, 0.25f, QVector3D(0.2f, 0.8f, 0.2f));
+    else
+      out.selectionRing(ringM, 0.35f, 0.15f, QVector3D(0.90f, 0.90f, 0.25f));
+  }
 }
 
-void registerArcherRenderer(EntityRendererRegistry &registry) {
+void registerArcherRenderer(Render::GL::EntityRendererRegistry &registry) {
   registry.registerRenderer("archer", [](const DrawContext &p,
                                          ISubmitter &out) {
     QVector3D tunic(0.8f, 0.9f, 1.0f);
@@ -327,157 +429,114 @@ void registerArcherRenderer(EntityRendererRegistry &registry) {
     } else if (rc) {
       tunic = QVector3D(rc->color[0], rc->color[1], rc->color[2]);
     }
-    auto tint = [&](float k) -> QVector3D {
-      return QVector3D(clamp01(tunic.x() * k), clamp01(tunic.y() * k),
-                       clamp01(tunic.z() * k));
-    };
-
-    const QVector3D skin(0.96f, 0.80f, 0.69f);
-    const QVector3D leather(0.35f, 0.22f, 0.12f);
-    const QVector3D wood(0.16f, 0.10f, 0.05f);
-    const QVector3D metal(0.65f, 0.66f, 0.70f);
-    const QVector3D fletch = tint(0.9f);
 
-    const QVector3D headPos(0.0f, 0.82f, 0.0f);
-    const QVector3D hipL(-0.12f, -0.45f, 0.03f);
-    const QVector3D hipR(0.12f, -0.45f, -0.03f);
-    const QVector3D footL(-0.14f, -1.00f, 0.06f);
-    const QVector3D footR(0.14f, -1.00f, -0.06f);
+    uint32_t seed = 0u;
+    if (unit)
+      seed ^= uint32_t(unit->ownerId * 2654435761u);
+    if (p.entity)
+      seed ^= uint32_t(reinterpret_cast<uintptr_t>(p.entity) & 0xFFFFFFFFu);
 
-    const QVector3D shoulderL(-0.18f, 0.35f, 0.00f);
-    const QVector3D shoulderR(0.18f, 0.35f, 0.00f);
+    const int rows = 2;
+    const int cols = 5;
+    const float spacing = 0.75f;
 
-    const QVector3D elbowL(-0.45f, 0.27f, 0.02f);
-    const QVector3D handL(-0.60f, 0.20f, 0.05f);
+    ArcherColors colors = makeColors(tunic);
 
-    const QVector3D elbowR(0.36f, 0.35f, 0.10f);
-    const QVector3D handR(0.25f, 0.45f, 0.15f);
-
-    const float bowTopY = 0.65f;
-    const float bowBotY = -0.15f;
-    const float bowX = -0.60f;
-    const float bowZMid = 0.05f;
-    const float bowDepth = 0.10f;
-    const float bowRodR = 0.02f;
-    const float stringR = 0.006f;
-
-    out.mesh(getArcherCapsule(), p.model, tint(0.8f), nullptr, 1.0f);
-
-    {
-      QMatrix4x4 M = p.model * sphereAt(headPos, 0.18f);
-      out.mesh(getUnitSphere(), M, skin, nullptr, 1.0f);
-    }
+    bool isMoving = false;
+    bool isAttacking = false;
+    float targetRotationY = 0.0f;
 
-    {
-      QMatrix4x4 ML = p.model * cylinderBetween(hipL, footL, 0.08f);
-      QMatrix4x4 MR = p.model * cylinderBetween(hipR, footR, 0.08f);
-      out.mesh(getUnitCylinder(), ML, leather, nullptr, 1.0f);
-      out.mesh(getUnitCylinder(), MR, leather, nullptr, 1.0f);
-    }
-
-    {
-
-      QMatrix4x4 M1 = p.model * cylinderBetween(shoulderL, elbowL, 0.06f);
-      QMatrix4x4 M2 = p.model * cylinderBetween(elbowL, handL, 0.055f);
-      out.mesh(getUnitCylinder(), M1, leather, nullptr, 1.0f);
-      out.mesh(getUnitCylinder(), M2, leather, nullptr, 1.0f);
-
-      QMatrix4x4 M3 = p.model * cylinderBetween(shoulderR, elbowR, 0.06f);
-      QMatrix4x4 M4 = p.model * cylinderBetween(elbowR, handR, 0.055f);
-      out.mesh(getUnitCylinder(), M3, leather, nullptr, 1.0f);
-      out.mesh(getUnitCylinder(), M4, leather, nullptr, 1.0f);
-
-      out.mesh(getUnitSphere(), p.model * sphereAt(handL, 0.07f), skin, nullptr,
-               1.0f);
-      out.mesh(getUnitSphere(), p.model * sphereAt(handR, 0.07f), skin, nullptr,
-               1.0f);
+    if (p.entity) {
+      auto *movement =
+          p.entity->getComponent<Engine::Core::MovementComponent>();
+      auto *attack = p.entity->getComponent<Engine::Core::AttackComponent>();
+      auto *attackTarget =
+          p.entity->getComponent<Engine::Core::AttackTargetComponent>();
+      auto *transform =
+          p.entity->getComponent<Engine::Core::TransformComponent>();
+
+      isMoving = (movement && movement->hasTarget);
+      isAttacking = (attack && attackTarget && attackTarget->targetId > 0);
+
+      if (isAttacking && attackTarget && p.world && transform) {
+        auto *target = p.world->getEntity(attackTarget->targetId);
+        if (target) {
+          auto *targetTransform =
+              target->getComponent<Engine::Core::TransformComponent>();
+          if (targetTransform) {
+
+            float dx = targetTransform->position.x - transform->position.x;
+            float dz = targetTransform->position.z - transform->position.z;
+
+            targetRotationY = std::atan2(dx, dz) * 180.0f / 3.14159f;
+          }
+        }
+      }
     }
 
-    {
-      QVector3D qTop(-0.05f, 0.85f, -0.28f);
-      QVector3D qBase(-0.10f, 0.25f, -0.22f);
-      QMatrix4x4 MQ = p.model * cylinderBetween(qBase, qTop, 0.06f);
-      out.mesh(getUnitCylinder(), MQ, leather, nullptr, 1.0f);
-
-      QVector3D a1 = qTop + QVector3D(0.00f, 0.06f, 0.00f);
-      QMatrix4x4 Mshaft1 = p.model * cylinderBetween(qTop, a1, 0.01f);
-      out.mesh(getUnitCylinder(), Mshaft1, wood, nullptr, 1.0f);
-      out.mesh(getUnitCone(),
-               p.model *
-                   coneFromTo(a1, a1 + QVector3D(0.0f, 0.06f, 0.0f), 0.03f),
-               fletch, nullptr, 1.0f);
-
-      QVector3D a2 = qTop + QVector3D(0.02f, 0.05f, 0.02f);
-      QMatrix4x4 Mshaft2 = p.model * cylinderBetween(qTop, a2, 0.01f);
-      out.mesh(getUnitCylinder(), Mshaft2, wood, nullptr, 1.0f);
-      out.mesh(getUnitCone(),
-               p.model *
-                   coneFromTo(a2, a2 + QVector3D(0.0f, 0.06f, 0.0f), 0.03f),
-               fletch, nullptr, 1.0f);
+    int visibleCount = rows * cols;
+    if (unit) {
+      int mh = std::max(1, unit->maxHealth);
+      float ratio = std::clamp(unit->health / float(mh), 0.0f, 1.0f);
+      visibleCount = std::max(1, (int)std::ceil(ratio * float(rows * cols)));
     }
 
-    {
-      const int segs = 12;
-      std::vector<QVector3D> bowPts;
-      bowPts.reserve(segs + 1);
-      for (int i = 0; i <= segs; ++i) {
-        float t = float(i) / float(segs);
-        float y = bowBotY + t * (bowTopY - bowBotY);
-
-        float z = bowZMid + bowDepth * std::sin((t - 0.5f) * 3.14159265f);
-        bowPts.push_back(QVector3D(bowX, y, z));
-      }
-      for (int i = 0; i < segs; ++i) {
-        QMatrix4x4 Mb =
-            p.model * cylinderBetween(bowPts[i], bowPts[i + 1], bowRodR);
-        out.mesh(getUnitCylinder(), Mb, wood, nullptr, 1.0f);
+    int idx = 0;
+    for (; idx < visibleCount; ++idx) {
+      int r = idx / cols;
+      int c = idx % cols;
+
+      float offsetX = (c - (cols - 1) * 0.5f) * spacing;
+      float offsetZ = (r - (rows - 1) * 0.5f) * spacing;
+
+      QMatrix4x4 instModel = p.model;
+
+      uint32_t instSeed = seed ^ uint32_t(idx * 9176u);
+
+      float yawOffset = (float)((int)(instSeed & 0xFFu) - 128) / 128.0f * 6.0f;
+
+      float phaseOffset = float((instSeed >> 8) & 0xFFu) / 255.0f * 0.25f;
+
+      if (p.entity) {
+        if (auto *entT =
+                p.entity->getComponent<Engine::Core::TransformComponent>()) {
+          QMatrix4x4 M;
+          M.setToIdentity();
+          M.translate(entT->position.x, entT->position.y, entT->position.z);
+          float baseYaw = entT->rotation.y;
+          float appliedYaw = baseYaw + (isAttacking ? yawOffset : 0.0f);
+          M.rotate(appliedYaw, 0.0f, 1.0f, 0.0f);
+          M.scale(entT->scale.x, entT->scale.y, entT->scale.z);
+
+          M.translate(offsetX, 0.0f, offsetZ);
+          instModel = M;
+        } else {
+          instModel = p.model;
+          if (isAttacking)
+            instModel.rotate(yawOffset, 0.0f, 1.0f, 0.0f);
+          instModel.translate(offsetX, 0.0f, offsetZ);
+        }
+      } else {
+        instModel = p.model;
+        if (isAttacking)
+          instModel.rotate(yawOffset, 0.0f, 1.0f, 0.0f);
+        instModel.translate(offsetX, 0.0f, offsetZ);
       }
 
-      QVector3D topEnd = bowPts.back();
-      QVector3D botEnd = bowPts.front();
-      QMatrix4x4 Ms1 = p.model * cylinderBetween(topEnd, handR, stringR);
-      QMatrix4x4 Ms2 = p.model * cylinderBetween(handR, botEnd, stringR);
-      out.mesh(getUnitCylinder(), Ms1, metal, nullptr, 1.0f);
-      out.mesh(getUnitCylinder(), Ms2, metal, nullptr, 1.0f);
-    }
+      DrawContext instCtx{p.resources, p.entity, p.world, instModel};
 
-    {
-      QVector3D tail = handR + QVector3D(-0.05f, 0.0f, 0.0f);
-      QVector3D tip = tail + QVector3D(0.0f, 0.0f, 0.70f);
-      QMatrix4x4 Mshaft = p.model * cylinderBetween(tail, tip, 0.01f);
-      out.mesh(getUnitCylinder(), Mshaft, wood, nullptr, 1.0f);
-
-      float headLen = 0.08f;
-      QVector3D headBase = tip - QVector3D(0.0f, 0.0f, headLen);
-      QMatrix4x4 Mhead = p.model * coneFromTo(headBase, tip, 0.03f);
-      out.mesh(getUnitCone(), Mhead, metal, nullptr, 1.0f);
-
-      QVector3D f1b = tail - QVector3D(0.0f, 0.0f, 0.02f);
-      QVector3D f1a = f1b - QVector3D(0.0f, 0.0f, 0.05f);
-      out.mesh(getUnitCone(), p.model * coneFromTo(f1b, f1a, 0.025f), fletch,
-               nullptr, 1.0f);
-
-      QVector3D f2b = tail + QVector3D(0.0f, 0.0f, 0.02f);
-      QVector3D f2a = f2b + QVector3D(0.0f, 0.0f, 0.05f);
-      out.mesh(getUnitCone(), p.model * coneFromTo(f2a, f2b, 0.025f), fletch,
-               nullptr, 1.0f);
-    }
+      ArcherPose pose = makePose(instSeed, p.animationTime + phaseOffset,
+                                 isMoving, isAttacking);
 
-    if (p.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);
-      out.selectionRing(ringM, 0.6f, 0.25f, QVector3D(0.2f, 0.8f, 0.2f));
-    }
-    if (p.hovered && !p.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);
-      out.selectionRing(ringM, 0.35f, 0.15f, QVector3D(0.90f, 0.90f, 0.25f));
+      drawQuiver(instCtx, out, colors, pose, instSeed);
+      drawLegs(instCtx, out, pose, colors);
+      drawTorso(instCtx, out, colors, pose);
+      drawArms(instCtx, out, pose, colors);
+      drawHeadAndNeck(instCtx, out, pose, colors);
+      drawBowAndArrow(instCtx, out, pose, colors);
     }
+
+    drawSelectionFX(p, out);
   });
 }
-
 } // namespace Render::GL

+ 420 - 38
render/entity/arrow_vfx_renderer.cpp

@@ -1,46 +1,428 @@
-#include "arrow_vfx_renderer.h"
-#include "../../game/systems/arrow_system.h"
-#include "../gl/resources.h"
-#include "../scene_renderer.h"
+#include "../../game/core/component.h"
+#include "../../game/core/entity.h"
+#include "../../game/visuals/team_colors.h"
+#include "../geom/math_utils.h"
+#include "../geom/selection_ring.h"
+#include "../geom/transforms.h"
+#include "../gl/mesh.h"
+#include "../gl/primitives.h"
+#include "../gl/texture.h"
+#include "archer_renderer.h"
 #include "registry.h"
-#include <algorithm>
-#include <cmath>
+
+#include <QMatrix4x4>
+#include <QVector3D>
+
+#include <cstdint>
 
 namespace Render::GL {
 
-void renderArrows(Renderer *renderer, ResourceManager *resources,
-                  const Game::Systems::ArrowSystem &arrowSystem) {
-  if (!renderer || !resources)
-    return;
-  auto *arrowMesh = resources->arrow();
-  if (!arrowMesh)
-    return;
-  const auto &arrows = arrowSystem.arrows();
-  for (const auto &arrow : arrows) {
-    if (!arrow.active)
-      continue;
-    const QVector3D delta = arrow.end - arrow.start;
-    const float dist = std::max(0.001f, delta.length());
-    QVector3D pos = arrow.start + delta * arrow.t;
-    float h = arrow.arcHeight * 4.0f * arrow.t * (1.0f - arrow.t);
-    pos.setY(pos.y() + h);
-    QMatrix4x4 model;
-    model.translate(pos.x(), pos.y(), pos.z());
-    QVector3D dir = delta.normalized();
-    float yawDeg = std::atan2(dir.x(), dir.z()) * 180.0f / 3.14159265f;
-    model.rotate(yawDeg, QVector3D(0, 1, 0));
-    float vy = (arrow.end.y() - arrow.start.y()) / dist;
-    float pitchDeg =
-        -std::atan2(vy - (8.0f * arrow.arcHeight * (arrow.t - 0.5f) / dist),
-                    1.0f) *
-        180.0f / 3.14159265f;
-    model.rotate(pitchDeg, QVector3D(1, 0, 0));
-    const float zScale = 0.40f;
-    const float xyScale = 0.26f;
-    model.translate(0.0f, 0.0f, -zScale * 0.5f);
-    model.scale(xyScale, xyScale, zScale);
-    renderer->mesh(arrowMesh, model, arrow.color, nullptr, 1.0f);
+using Render::Geom::clamp01;
+using Render::Geom::clampf;
+using Render::Geom::clampVec01;
+using Render::Geom::coneFromTo;
+using Render::Geom::cylinderBetween;
+using Render::Geom::sphereAt;
+
+struct HumanProportions {
+
+  static constexpr float TOTAL_HEIGHT = 2.00f;
+  static constexpr float HEAD_HEIGHT = 0.25f;
+
+  static constexpr float GROUND_Y = -1.00f;
+  static constexpr float HEAD_TOP_Y = GROUND_Y + TOTAL_HEIGHT;
+  static constexpr float CHIN_Y = HEAD_TOP_Y - HEAD_HEIGHT;
+  static constexpr float NECK_BASE_Y = CHIN_Y - 0.10f;
+  static constexpr float SHOULDER_Y = NECK_BASE_Y - 0.15f;
+  static constexpr float CHEST_Y = SHOULDER_Y - 0.35f;
+  static constexpr float WAIST_Y = CHEST_Y - 0.30f;
+  static constexpr float HIP_Y = WAIST_Y - 0.15f;
+  static constexpr float KNEE_Y = HIP_Y - 0.35f;
+
+  static constexpr float SHOULDER_WIDTH = HEAD_HEIGHT * 1.6f;
+  static constexpr float HEAD_RADIUS = HEAD_HEIGHT * 0.40f;
+  static constexpr float NECK_RADIUS = HEAD_RADIUS * 0.35f;
+  static constexpr float TORSO_TOP_R = HEAD_RADIUS * 1.0f;
+  static constexpr float TORSO_BOT_R = HEAD_RADIUS * 0.9f;
+  static constexpr float UPPER_ARM_R = HEAD_RADIUS * 0.30f;
+  static constexpr float FORE_ARM_R = HEAD_RADIUS * 0.25f;
+  static constexpr float HAND_RADIUS = HEAD_RADIUS * 0.22f;
+  static constexpr float UPPER_LEG_R = HEAD_RADIUS * 0.38f;
+  static constexpr float LOWER_LEG_R = HEAD_RADIUS * 0.32f;
+
+  static constexpr float UPPER_ARM_LEN = 0.28f;
+  static constexpr float FORE_ARM_LEN = 0.30f;
+  static constexpr float UPPER_LEG_LEN = 0.35f;
+  static constexpr float LOWER_LEG_LEN = 0.35f;
+};
+
+struct ArcherColors {
+  QVector3D tunic, skin, leather, leatherDark, wood, metal, metalHead,
+      stringCol, fletch;
+};
+
+struct ArcherPose {
+  using P = HumanProportions;
+
+  QVector3D headPos{0.0f, (P::HEAD_TOP_Y + P::CHIN_Y) * 0.5f, 0.0f};
+  float headR = P::HEAD_RADIUS;
+  QVector3D neckBase{0.0f, P::NECK_BASE_Y, 0.0f};
+
+  QVector3D shoulderL{-P::SHOULDER_WIDTH * 0.5f, P::SHOULDER_Y, 0.1f};
+  QVector3D shoulderR{P::SHOULDER_WIDTH * 0.5f, P::SHOULDER_Y, 0.1f};
+
+  QVector3D elbowL, elbowR;
+  QVector3D handL, handR;
+
+  float hipSpacing = P::SHOULDER_WIDTH * 0.55f;
+  QVector3D hipL{-hipSpacing, P::HIP_Y, 0.03f};
+  QVector3D hipR{hipSpacing, P::HIP_Y, -0.03f};
+
+  QVector3D footL{-hipSpacing * 1.6f, P::GROUND_Y, 0.10f};
+  QVector3D footR{hipSpacing * 1.6f, P::GROUND_Y, -0.10f};
+
+  float bowX = 0.0f;
+  float bowTopY = P::SHOULDER_Y + 0.55f;
+  float bowBotY = P::HIP_Y - 0.25f;
+  float bowRodR = 0.035f;
+  float stringR = 0.008f;
+  float bowDepth = 0.25f;
+};
+
+static inline ArcherPose makePose(uint32_t seed) {
+  (void)seed;
+  ArcherPose P;
+
+  using HP = HumanProportions;
+
+  P.handL = QVector3D(P.bowX - 0.05f, HP::SHOULDER_Y + 0.05f, 0.55f);
+
+  P.handR = QVector3D(0.15f, HP::SHOULDER_Y + 0.15f, 0.20f);
+
+  QVector3D shoulderToHandL = P.handL - P.shoulderL;
+  float distL = shoulderToHandL.length();
+  QVector3D dirL = shoulderToHandL.normalized();
+
+  QVector3D perpL(-dirL.z(), 0.0f, dirL.x());
+  float elbowOffsetL = 0.15f;
+  P.elbowL = P.shoulderL + dirL * (distL * 0.45f) + perpL * elbowOffsetL +
+             QVector3D(0, -0.08f, 0);
+
+  QVector3D shoulderToHandR = P.handR - P.shoulderR;
+  float distR = shoulderToHandR.length();
+  QVector3D dirR = shoulderToHandR.normalized();
+
+  QVector3D perpR(-dirR.z(), 0.0f, dirR.x());
+  float elbowOffsetR = 0.12f;
+  P.elbowR = P.shoulderR + dirR * (distR * 0.48f) + perpR * elbowOffsetR +
+             QVector3D(0, 0.02f, 0);
+
+  return P;
+}
+
+static inline ArcherColors makeColors(const QVector3D &teamTint) {
+  ArcherColors C;
+  auto tint = [&](float k) {
+    return QVector3D(clamp01(teamTint.x() * k), clamp01(teamTint.y() * k),
+                     clamp01(teamTint.z() * k));
+  };
+  C.tunic = teamTint;
+  C.skin = QVector3D(0.96f, 0.80f, 0.69f);
+  C.leather = QVector3D(0.35f, 0.22f, 0.12f);
+  C.leatherDark = C.leather * 0.9f;
+  C.wood = QVector3D(0.16f, 0.10f, 0.05f);
+  C.metal = QVector3D(0.65f, 0.66f, 0.70f);
+  C.metalHead = clampVec01(C.metal * 1.1f);
+  C.stringCol = QVector3D(0.30f, 0.30f, 0.32f);
+  C.fletch = tint(0.9f);
+  return C;
+}
+
+static inline void drawTorso(const DrawContext &p, ISubmitter &out,
+                             const ArcherColors &C, const ArcherPose &P) {
+  using HP = HumanProportions;
+
+  QVector3D torsoTop{0.0f, HP::NECK_BASE_Y - 0.05f, 0.0f};
+  QVector3D torsoBot{0.0f, HP::WAIST_Y, 0.0f};
+
+  float torsoRadius = HP::TORSO_TOP_R;
+
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(torsoTop, torsoBot, torsoRadius), C.tunic,
+           nullptr, 1.0f);
+
+  QVector3D waist{0.0f, HP::WAIST_Y, 0.0f};
+  QVector3D hipCenter = (P.hipL + P.hipR) * 0.5f;
+
+  out.mesh(getUnitCone(),
+           p.model * coneFromTo(waist, hipCenter, HP::TORSO_BOT_R),
+           C.tunic * 0.9f, nullptr, 1.0f);
+}
+
+static inline void drawHeadAndNeck(const DrawContext &p, ISubmitter &out,
+                                   const ArcherPose &P, const ArcherColors &C) {
+  using HP = HumanProportions;
+
+  QVector3D chinPos{0.0f, HP::CHIN_Y, 0.0f};
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.neckBase, chinPos, HP::NECK_RADIUS),
+           C.skin * 0.9f, nullptr, 1.0f);
+
+  out.mesh(getUnitSphere(), p.model * sphereAt(P.headPos, P.headR), C.skin,
+           nullptr, 1.0f);
+
+  QVector3D iris(0.06f, 0.06f, 0.07f);
+  float eyeZ = P.headR * 0.7f;
+  float eyeY = P.headPos.y() + P.headR * 0.1f;
+  float eyeSpacing = P.headR * 0.35f;
+  out.mesh(getUnitSphere(),
+           p.model *
+               sphereAt(QVector3D(-eyeSpacing, eyeY, eyeZ), P.headR * 0.15f),
+           iris, nullptr, 1.0f);
+  out.mesh(getUnitSphere(),
+           p.model *
+               sphereAt(QVector3D(eyeSpacing, eyeY, eyeZ), P.headR * 0.15f),
+           iris, nullptr, 1.0f);
+
+  QVector3D domeC = P.headPos + QVector3D(0.0f, P.headR * 0.25f, 0.0f);
+  float domeR = P.headR * 1.05f;
+  out.mesh(getUnitSphere(), p.model * sphereAt(domeC, domeR), C.metal, nullptr,
+           1.0f);
+
+  QVector3D visorBase(0.0f, P.headPos.y() + P.headR * 0.10f, P.headR * 0.80f);
+  QVector3D visorTip = visorBase + QVector3D(0.0f, -0.015f, 0.06f);
+  out.mesh(getUnitCone(),
+           p.model * coneFromTo(visorBase, visorTip, P.headR * 0.38f),
+           C.metal * 0.92f, nullptr, 1.0f);
+
+  QVector3D cheekL0(-P.headR * 0.85f, P.headPos.y() + P.headR * 0.05f, 0.02f);
+  QVector3D cheekL1(-P.headR * 0.85f, P.headPos.y() - P.headR * 0.20f, 0.04f);
+  QVector3D cheekR0(P.headR * 0.85f, P.headPos.y() + P.headR * 0.05f, -0.02f);
+  QVector3D cheekR1(P.headR * 0.85f, P.headPos.y() - P.headR * 0.20f, -0.04f);
+  out.mesh(getUnitCone(),
+           p.model * coneFromTo(cheekL0, cheekL1, P.headR * 0.24f),
+           C.metal * 0.95f, nullptr, 1.0f);
+  out.mesh(getUnitCone(),
+           p.model * coneFromTo(cheekR0, cheekR1, P.headR * 0.24f),
+           C.metal * 0.95f, nullptr, 1.0f);
+}
+
+static inline void drawArms(const DrawContext &p, ISubmitter &out,
+                            const ArcherPose &P, const ArcherColors &C) {
+  using HP = HumanProportions;
+
+  const float upperArmR = HP::UPPER_ARM_R;
+  const float foreArmR = HP::FORE_ARM_R;
+  const float jointR = upperArmR * 1.2f;
+
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.shoulderL, P.elbowL, upperArmR), C.tunic,
+           nullptr, 1.0f);
+
+  out.mesh(getUnitSphere(), p.model * sphereAt(P.elbowL, jointR),
+           C.tunic * 0.95f, nullptr, 1.0f);
+
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.elbowL, P.handL, foreArmR),
+           C.skin * 0.95f, nullptr, 1.0f);
+
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.shoulderR, P.elbowR, upperArmR), C.tunic,
+           nullptr, 1.0f);
+
+  out.mesh(getUnitSphere(), p.model * sphereAt(P.elbowR, jointR),
+           C.tunic * 0.95f, nullptr, 1.0f);
+
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.elbowR, P.handR, foreArmR),
+           C.skin * 0.95f, nullptr, 1.0f);
+}
+
+static inline void drawLegs(const DrawContext &p, ISubmitter &out,
+                            const ArcherPose &P, const ArcherColors &C) {
+  using HP = HumanProportions;
+
+  const float thighR = HP::UPPER_LEG_R;
+  const float shinR = HP::LOWER_LEG_R;
+  const float kneeJointR = thighR * 1.15f;
+
+  auto makeKnee = [&](const QVector3D &hip, const QVector3D &foot,
+                      float outwardSign) {
+    const float t = 0.38f;
+    QVector3D knee = hip * (1.0f - t) + foot * t;
+    knee.setY(HP::KNEE_Y + 0.03f);
+    knee.setZ(knee.z() + 0.05f);
+    knee.setX(knee.x() + outwardSign * 0.06f);
+    return knee;
+  };
+
+  QVector3D kneeL = makeKnee(P.hipL, P.footL, -1.0f);
+  QVector3D kneeR = makeKnee(P.hipR, P.footR, 1.0f);
+
+  out.mesh(getUnitCone(), p.model * coneFromTo(P.hipL, kneeL, thighR),
+           C.leather, nullptr, 1.0f);
+  out.mesh(getUnitCone(), p.model * coneFromTo(P.hipR, kneeR, thighR),
+           C.leather, nullptr, 1.0f);
+
+  out.mesh(getUnitSphere(), p.model * sphereAt(kneeL, kneeJointR),
+           C.leather * 0.95f, nullptr, 1.0f);
+  out.mesh(getUnitSphere(), p.model * sphereAt(kneeR, kneeJointR),
+           C.leather * 0.95f, nullptr, 1.0f);
+
+  out.mesh(getUnitCone(), p.model * coneFromTo(kneeL, P.footL, shinR),
+           C.leatherDark, nullptr, 1.0f);
+  out.mesh(getUnitCone(), p.model * coneFromTo(kneeR, P.footR, shinR),
+           C.leatherDark, nullptr, 1.0f);
+
+  QVector3D down(0.0f, -0.02f, 0.0f);
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.footL, P.footL + down, shinR * 1.1f),
+           C.leatherDark, nullptr, 1.0f);
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(P.footR, P.footR + down, shinR * 1.1f),
+           C.leatherDark, nullptr, 1.0f);
+}
+
+static inline void drawQuiver(const DrawContext &p, ISubmitter &out,
+                              const ArcherColors &C, const ArcherPose &P,
+                              uint32_t seed) {
+  using HP = HumanProportions;
+
+  auto hash01 = [](uint32_t x) {
+    x ^= x << 13;
+    x ^= x >> 17;
+    x ^= x << 5;
+    return (x & 0x00FFFFFF) / float(0x01000000);
+  };
+
+  QVector3D qTop(-0.08f, HP::SHOULDER_Y + 0.10f, -0.25f);
+  QVector3D qBase(-0.10f, HP::CHEST_Y, -0.22f);
+
+  float quiverR = HP::HEAD_RADIUS * 0.45f;
+  out.mesh(getUnitCylinder(), p.model * cylinderBetween(qBase, qTop, quiverR),
+           C.leather, nullptr, 1.0f);
+
+  float j = (hash01(seed) - 0.5f) * 0.04f;
+  float k = (hash01(seed ^ 0x9E3779B9u) - 0.5f) * 0.04f;
+
+  QVector3D a1 = qTop + QVector3D(0.00f + j, 0.08f, 0.00f + k);
+  out.mesh(getUnitCylinder(), p.model * cylinderBetween(qTop, a1, 0.010f),
+           C.wood, nullptr, 1.0f);
+  out.mesh(getUnitCone(),
+           p.model * coneFromTo(a1, a1 + QVector3D(0, 0.05f, 0), 0.025f),
+           C.fletch, nullptr, 1.0f);
+
+  QVector3D a2 = qTop + QVector3D(0.02f - j, 0.07f, 0.02f - k);
+  out.mesh(getUnitCylinder(), p.model * cylinderBetween(qTop, a2, 0.010f),
+           C.wood, nullptr, 1.0f);
+  out.mesh(getUnitCone(),
+           p.model * coneFromTo(a2, a2 + QVector3D(0, 0.05f, 0), 0.025f),
+           C.fletch, nullptr, 1.0f);
+}
+
+static inline void drawBowAndArrow(const DrawContext &p, ISubmitter &out,
+                                   const ArcherPose &P, const ArcherColors &C) {
+  const QVector3D up(0.0f, 1.0f, 0.0f);
+  const QVector3D forward(0.0f, 0.0f, 1.0f);
+
+  QVector3D grip = P.handL;
+  QVector3D topEnd(P.bowX, P.bowTopY, grip.z());
+  QVector3D botEnd(P.bowX, P.bowBotY, grip.z());
+
+  QVector3D nock(P.bowX,
+                 clampf(P.handR.y(), P.bowBotY + 0.05f, P.bowTopY - 0.05f),
+                 clampf(P.handR.z(), grip.z() - 0.30f, grip.z() + 0.30f));
+
+  const int segs = 22;
+  auto qBezier = [](const QVector3D &a, const QVector3D &c, const QVector3D &b,
+                    float t) {
+    float u = 1.0f - t;
+    return u * u * a + 2.0f * u * t * c + t * t * b;
+  };
+  QVector3D ctrl = nock + forward * P.bowDepth;
+  QVector3D prev = botEnd;
+  for (int i = 1; i <= segs; ++i) {
+    float t = float(i) / float(segs);
+    QVector3D cur = qBezier(botEnd, ctrl, topEnd, t);
+    out.mesh(getUnitCylinder(), p.model * cylinderBetween(prev, cur, P.bowRodR),
+             C.wood, nullptr, 1.0f);
+    prev = cur;
   }
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(grip - up * 0.05f, grip + up * 0.05f,
+                                     P.bowRodR * 1.45f),
+           C.wood, nullptr, 1.0f);
+
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(topEnd, nock, P.stringR), C.stringCol,
+           nullptr, 1.0f);
+  out.mesh(getUnitCylinder(),
+           p.model * cylinderBetween(nock, botEnd, P.stringR), C.stringCol,
+           nullptr, 1.0f);
+  out.mesh(getUnitCylinder(), p.model * cylinderBetween(P.handR, nock, 0.0045f),
+           C.stringCol * 0.9f, nullptr, 1.0f);
+
+  QVector3D tail = nock - forward * 0.06f;
+  QVector3D tip = tail + forward * 0.90f;
+  out.mesh(getUnitCylinder(), p.model * cylinderBetween(tail, tip, 0.035f),
+           C.wood, nullptr, 1.0f);
+  QVector3D headBase = tip - forward * 0.10f;
+  out.mesh(getUnitCone(), p.model * coneFromTo(headBase, tip, 0.05f),
+           C.metalHead, nullptr, 1.0f);
+  QVector3D f1b = tail - forward * 0.02f, f1a = f1b - forward * 0.06f;
+  QVector3D f2b = tail + forward * 0.02f, f2a = f2b + forward * 0.06f;
+  out.mesh(getUnitCone(), p.model * coneFromTo(f1b, f1a, 0.04f), C.fletch,
+           nullptr, 1.0f);
+  out.mesh(getUnitCone(), p.model * coneFromTo(f2a, f2b, 0.04f), C.fletch,
+           nullptr, 1.0f);
+}
+
+static inline void drawSelectionFX(const DrawContext &p, ISubmitter &out) {
+  if (p.selected || p.hovered) {
+    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);
+    if (p.selected)
+      out.selectionRing(ringM, 0.6f, 0.25f, QVector3D(0.2f, 0.8f, 0.2f));
+    else
+      out.selectionRing(ringM, 0.35f, 0.15f, QVector3D(0.90f, 0.90f, 0.25f));
+  }
+}
+
+void registerArcherRenderer(Render::GL::EntityRendererRegistry &registry) {
+  registry.registerRenderer(
+      "archer", [](const DrawContext &p, ISubmitter &out) {
+        QVector3D tunic(0.8f, 0.9f, 1.0f);
+        Engine::Core::UnitComponent *unit = nullptr;
+        Engine::Core::RenderableComponent *rc = nullptr;
+        if (p.entity) {
+          unit = p.entity->getComponent<Engine::Core::UnitComponent>();
+          rc = p.entity->getComponent<Engine::Core::RenderableComponent>();
+        }
+        if (unit && unit->ownerId > 0) {
+          tunic = Game::Visuals::teamColorForOwner(unit->ownerId);
+        } else if (rc) {
+          tunic = QVector3D(rc->color[0], rc->color[1], rc->color[2]);
+        }
+
+        uint32_t seed = 0u;
+        if (unit)
+          seed ^= uint32_t(unit->ownerId * 2654435761u);
+        if (p.entity)
+          seed ^= uint32_t(reinterpret_cast<uintptr_t>(p.entity) & 0xFFFFFFFFu);
+
+        ArcherPose pose = makePose(seed);
+        ArcherColors colors = makeColors(tunic);
+
+        drawQuiver(p, out, colors, pose, seed);
+        drawLegs(p, out, pose, colors);
+        drawTorso(p, out, colors, pose);
+        drawArms(p, out, pose, colors);
+        drawHeadAndNeck(p, out, pose, colors);
+        drawBowAndArrow(p, out, pose, colors);
+        drawSelectionFX(p, out);
+      });
 }
 
 } // namespace Render::GL

+ 457 - 172
render/entity/barracks_renderer.cpp

@@ -1,204 +1,489 @@
 #include "barracks_renderer.h"
 #include "../../game/core/component.h"
 #include "../geom/flag.h"
+#include "../geom/math_utils.h"
+#include "../geom/transforms.h"
+#include "../gl/primitives.h"
 #include "../gl/resources.h"
 #include "registry.h"
 
+#include <QMatrix4x4>
+#include <QVector3D>
+
 namespace Render::GL {
+namespace {
+
+using Render::Geom::clamp01;
+using Render::Geom::clampVec01;
+using Render::Geom::cylinderBetween;
+using Render::Geom::sphereAt;
+
+struct BuildingProportions {
+
+  static constexpr float baseWidth = 2.4f;
+  static constexpr float baseDepth = 2.0f;
+  static constexpr float baseHeight = 1.8f;
+  static constexpr float foundationHeight = 0.2f;
+
+  static constexpr float wallThickness = 0.08f;
+  static constexpr float beamThickness = 0.12f;
+  static constexpr float cornerPostRadius = 0.08f;
+
+  static constexpr float roofPitch = 0.8f;
+  static constexpr float roofOverhang = 0.15f;
+  static constexpr float thatchLayerHeight = 0.12f;
+
+  static constexpr float annexWidth = 1.0f;
+  static constexpr float annexDepth = 1.0f;
+  static constexpr float annexHeight = 1.2f;
+  static constexpr float annexRoofHeight = 0.5f;
+
+  static constexpr float doorWidth = 0.5f;
+  static constexpr float doorHeight = 0.8f;
+  static constexpr float windowWidth = 0.4f;
+  static constexpr float windowHeight = 0.5f;
+  static constexpr float chimneyWidth = 0.25f;
+  static constexpr float chimneyHeight = 1.0f;
+  static constexpr float chimneyCapSize = 0.35f;
+
+  static constexpr float bannerPoleHeight = 2.0f;
+  static constexpr float bannerPoleRadius = 0.05f;
+  static constexpr float bannerWidth = 0.5f;
+  static constexpr float bannerHeight = 0.6f;
+};
+
+struct BarracksPalette {
+  QVector3D plaster{0.92f, 0.88f, 0.78f};
+  QVector3D plasterShade{0.78f, 0.74f, 0.64f};
+  QVector3D timber{0.35f, 0.25f, 0.15f};
+  QVector3D timberLight{0.50f, 0.38f, 0.22f};
+  QVector3D woodDark{0.30f, 0.20f, 0.12f};
+  QVector3D thatch{0.82f, 0.70f, 0.28f};
+  QVector3D thatchDark{0.68f, 0.58f, 0.22f};
+  QVector3D stone{0.55f, 0.54f, 0.52f};
+  QVector3D stoneDark{0.42f, 0.41f, 0.39f};
+  QVector3D door{0.28f, 0.20f, 0.12f};
+  QVector3D window{0.35f, 0.42f, 0.48f};
+  QVector3D path{0.62f, 0.60f, 0.54f};
+  QVector3D crate{0.48f, 0.34f, 0.18f};
+  QVector3D team{0.8f, 0.9f, 1.0f};
+  QVector3D teamTrim{0.48f, 0.54f, 0.60f};
+};
+
+static inline BarracksPalette makePalette(const QVector3D &team) {
+  BarracksPalette p;
+  p.team = clampVec01(team);
+  p.teamTrim =
+      clampVec01(QVector3D(team.x() * 0.6f, team.y() * 0.6f, team.z() * 0.6f));
+  return p;
+}
 
-static void drawBarracks(const DrawContext &p, ISubmitter &out) {
-  if (!p.resources || !p.entity)
-    return;
-  auto *t = p.entity->getComponent<Engine::Core::TransformComponent>();
-  auto *r = p.entity->getComponent<Engine::Core::RenderableComponent>();
-  auto *u = p.entity->getComponent<Engine::Core::UnitComponent>();
-  if (!t || !r)
-    return;
+static inline void drawCylinder(ISubmitter &out, const QMatrix4x4 &model,
+                                const QVector3D &a, const QVector3D &b,
+                                float radius, const QVector3D &color,
+                                Texture *white) {
+  out.mesh(getUnitCylinder(), cylinderBetween(a, b, radius) * model, color,
+           white, 1.0f);
+}
+
+static inline void drawSphere(ISubmitter &out, const QMatrix4x4 &model,
+                              const QVector3D &pos, float radius,
+                              const QVector3D &color, Texture *white) {
+  out.mesh(getUnitSphere(), sphereAt(pos, radius) * model, color, white, 1.0f);
+}
+
+static inline void unitBox(ISubmitter &out, Mesh *unitMesh, Texture *white,
+                           const QMatrix4x4 &model, const QVector3D &t,
+                           const QVector3D &s, const QVector3D &color) {
+  QMatrix4x4 M = model;
+  M.translate(t);
+  M.scale(s);
+  out.mesh(unitMesh, M, color, white, 1.0f);
+}
+
+static inline void unitBox(ISubmitter &out, Mesh *unitMesh, Texture *white,
+                           const QMatrix4x4 &model, const QVector3D &s,
+                           const QVector3D &color) {
+  QMatrix4x4 M = model;
+  M.scale(s);
+  out.mesh(unitMesh, M, color, white, 1.0f);
+}
+
+static inline void drawFoundation(const DrawContext &p, ISubmitter &out,
+                                  Mesh *unit, Texture *white,
+                                  const BarracksPalette &C) {
+  constexpr float baseWidth = BuildingProportions::baseWidth;
+  constexpr float baseDepth = BuildingProportions::baseDepth;
+  constexpr float foundationHeight = BuildingProportions::foundationHeight;
+
+  unitBox(out, unit, white, p.model,
+          QVector3D(0.0f, -foundationHeight / 2, 0.0f),
+          QVector3D(baseWidth / 2 + 0.1f, foundationHeight / 2,
+                    baseDepth / 2 + 0.1f),
+          C.stoneDark);
+}
 
-  const QVector3D stone(0.55f, 0.55f, 0.55f);
-  const QVector3D wood(0.50f, 0.33f, 0.18f);
-  const QVector3D woodDark(0.35f, 0.23f, 0.12f);
-  const QVector3D thatch(0.85f, 0.72f, 0.25f);
-  const QVector3D doorColor(0.25f, 0.18f, 0.10f);
-  const QVector3D crate(0.45f, 0.30f, 0.14f);
-  const QVector3D path(0.60f, 0.58f, 0.50f);
-  const QVector3D team = QVector3D(r->color[0], r->color[1], r->color[2]);
-
-  QMatrix4x4 foundation = p.model;
-  foundation.translate(0.0f, -0.15f, 0.0f);
-  foundation.scale(1.35f, 0.10f, 1.2f);
-  out.mesh(p.resources->unit(), foundation, stone, p.resources->white(), 1.0f);
-
-  QMatrix4x4 walls = p.model;
-  walls.scale(1.15f, 0.65f, 0.95f);
-  out.mesh(p.resources->unit(), walls, wood, p.resources->white(), 1.0f);
-
-  auto drawBeam = [&](float x, float z) {
-    QMatrix4x4 b = p.model;
-    b.translate(x, 0.20f, z);
-    b.scale(0.06f, 0.75f, 0.06f);
-    out.mesh(p.resources->unit(), b, woodDark, p.resources->white(), 1.0f);
+static inline void drawTimberFrame(const DrawContext &p, ISubmitter &out,
+                                   Mesh *unit, Texture *white,
+                                   const BarracksPalette &C) {
+  constexpr float baseWidth = BuildingProportions::baseWidth;
+  constexpr float baseDepth = BuildingProportions::baseDepth;
+  constexpr float baseHeight = BuildingProportions::baseHeight;
+  constexpr float cornerPostRadius = BuildingProportions::cornerPostRadius;
+  constexpr float beamThickness = BuildingProportions::beamThickness;
+
+  auto cornerPost = [&](float x, float z) {
+    QVector3D bottom(x, 0.0f, z);
+    QVector3D top(x, baseHeight, z);
+    drawCylinder(out, p.model, bottom, top, cornerPostRadius, C.timber, white);
   };
-  drawBeam(0.9f, 0.7f);
-  drawBeam(-0.9f, 0.7f);
-  drawBeam(0.9f, -0.7f);
-  drawBeam(-0.9f, -0.7f);
-
-  QMatrix4x4 roofBase = p.model;
-  roofBase.translate(0.0f, 0.55f, 0.0f);
-  roofBase.scale(1.25f, 0.18f, 1.05f);
-  out.mesh(p.resources->unit(), roofBase, thatch, p.resources->white(), 1.0f);
-
-  QMatrix4x4 roofMid = p.model;
-  roofMid.translate(0.0f, 0.72f, 0.0f);
-  roofMid.scale(1.05f, 0.14f, 0.95f);
-  out.mesh(p.resources->unit(), roofMid, thatch, p.resources->white(), 1.0f);
-
-  QMatrix4x4 roofRidge = p.model;
-  roofRidge.translate(0.0f, 0.86f, 0.0f);
-  roofRidge.scale(0.85f, 0.12f, 0.85f);
-  out.mesh(p.resources->unit(), roofRidge, thatch, p.resources->white(), 1.0f);
-
-  QMatrix4x4 ridge = p.model;
-  ridge.translate(0.0f, 0.96f, 0.0f);
-  ridge.scale(1.1f, 0.04f, 0.12f);
-  out.mesh(p.resources->unit(), ridge, woodDark, p.resources->white(), 1.0f);
-
-  QMatrix4x4 door = p.model;
-  door.translate(0.0f, -0.02f, 0.62f);
-  door.scale(0.25f, 0.38f, 0.06f);
-  out.mesh(p.resources->unit(), door, doorColor, p.resources->white(), 1.0f);
-
-  QMatrix4x4 annex = p.model;
-  annex.translate(0.95f, -0.05f, -0.15f);
-  annex.scale(0.55f, 0.45f, 0.55f);
-  out.mesh(p.resources->unit(), annex, wood, p.resources->white(), 1.0f);
-
-  QMatrix4x4 annexRoof = p.model;
-  annexRoof.translate(0.95f, 0.30f, -0.15f);
-  annexRoof.scale(0.60f, 0.12f, 0.60f);
-  out.mesh(p.resources->unit(), annexRoof, thatch, p.resources->white(), 1.0f);
-
-  QMatrix4x4 chimney = p.model;
-  chimney.translate(-0.65f, 0.75f, -0.55f);
-  chimney.scale(0.10f, 0.35f, 0.10f);
-  out.mesh(p.resources->unit(), chimney, stone, p.resources->white(), 1.0f);
-
-  QMatrix4x4 chimneyCap = p.model;
-  chimneyCap.translate(-0.65f, 0.95f, -0.55f);
-  chimneyCap.scale(0.16f, 0.05f, 0.16f);
-  out.mesh(p.resources->unit(), chimneyCap, stone, p.resources->white(), 1.0f);
-
-  auto drawPaver = [&](float ox, float oz, float sx, float sz) {
-    QMatrix4x4 paver = p.model;
-    paver.translate(ox, -0.14f, oz);
-    paver.scale(sx, 0.02f, sz);
-    out.mesh(p.resources->unit(), paver, path, p.resources->white(), 1.0f);
+
+  float hw = baseWidth / 2 - cornerPostRadius;
+  float hd = baseDepth / 2 - cornerPostRadius;
+  cornerPost(hw, hd);
+  cornerPost(-hw, hd);
+  cornerPost(hw, -hd);
+  cornerPost(-hw, -hd);
+
+  auto beam = [&](const QVector3D &a, const QVector3D &b) {
+    drawCylinder(out, p.model, a, b, beamThickness * 0.6f, C.timber, white);
   };
-  drawPaver(0.0f, 0.9f, 0.25f, 0.20f);
-  drawPaver(0.0f, 1.15f, 0.22f, 0.18f);
-  drawPaver(0.0f, 1.35f, 0.20f, 0.16f);
-
-  QMatrix4x4 crate1 = p.model;
-  crate1.translate(0.45f, -0.05f, 0.55f);
-  crate1.scale(0.18f, 0.18f, 0.18f);
-  out.mesh(p.resources->unit(), crate1, crate, p.resources->white(), 1.0f);
-  QMatrix4x4 crate2 = p.model;
-  crate2.translate(0.58f, 0.02f, 0.45f);
-  crate2.scale(0.14f, 0.14f, 0.14f);
-  out.mesh(p.resources->unit(), crate2, crate, p.resources->white(), 1.0f);
+
+  float y = baseHeight;
+  beam(QVector3D(-hw, y, hd), QVector3D(hw, y, hd));
+  beam(QVector3D(-hw, y, -hd), QVector3D(hw, y, -hd));
+  beam(QVector3D(-hw, y, -hd), QVector3D(-hw, y, hd));
+  beam(QVector3D(hw, y, -hd), QVector3D(hw, y, hd));
+
+  y = baseHeight * 0.5f;
+  beam(QVector3D(-hw, y, hd), QVector3D(hw, y, hd));
+  beam(QVector3D(-hw, y, -hd), QVector3D(hw, y, -hd));
+}
+
+static inline void drawWalls(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                             Texture *white, const BarracksPalette &C) {
+  constexpr float baseWidth = BuildingProportions::baseWidth;
+  constexpr float baseDepth = BuildingProportions::baseDepth;
+  constexpr float baseHeight = BuildingProportions::baseHeight;
+  constexpr float wallThickness = BuildingProportions::wallThickness;
+  constexpr float cornerPostRadius = BuildingProportions::cornerPostRadius;
+
+  float wallY = baseHeight / 2;
+  float wallH = baseHeight * 0.85f;
+
+  unitBox(out, unit, white, p.model,
+          QVector3D(-baseWidth / 2 + 0.35f, wallY,
+                    baseDepth / 2 - wallThickness / 2),
+          QVector3D(0.55f, wallH / 2, wallThickness / 2), C.plaster);
+  unitBox(out, unit, white, p.model,
+          QVector3D(baseWidth / 2 - 0.35f, wallY,
+                    baseDepth / 2 - wallThickness / 2),
+          QVector3D(0.55f, wallH / 2, wallThickness / 2), C.plaster);
+
+  unitBox(
+      out, unit, white, p.model,
+      QVector3D(0.0f, wallY, -baseDepth / 2 + wallThickness / 2),
+      QVector3D(baseWidth / 2 - cornerPostRadius, wallH / 2, wallThickness / 2),
+      C.plaster);
+
+  unitBox(
+      out, unit, white, p.model,
+      QVector3D(-baseWidth / 2 + wallThickness / 2, wallY, 0.0f),
+      QVector3D(wallThickness / 2, wallH / 2, baseDepth / 2 - cornerPostRadius),
+      C.plasterShade);
+
+  unitBox(
+      out, unit, white, p.model,
+      QVector3D(baseWidth / 2 - wallThickness / 2, wallY, 0.0f),
+      QVector3D(wallThickness / 2, wallH / 2, baseDepth / 2 - cornerPostRadius),
+      C.plasterShade);
+}
+
+static inline void drawRoofs(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                             Texture *white, const BarracksPalette &C) {
+  constexpr float baseWidth = BuildingProportions::baseWidth;
+  constexpr float baseDepth = BuildingProportions::baseDepth;
+  constexpr float baseHeight = BuildingProportions::baseHeight;
+  constexpr float roofPitch = BuildingProportions::roofPitch;
+  constexpr float roofOverhang = BuildingProportions::roofOverhang;
+  constexpr float thatchLayerHeight = BuildingProportions::thatchLayerHeight;
+
+  float roofBase = baseHeight;
+  float roofPeak = roofBase + roofPitch;
+
+  float layerWidth = baseWidth / 2 + roofOverhang;
+  float layerDepth = baseDepth / 2 + roofOverhang;
+
+  unitBox(out, unit, white, p.model,
+          QVector3D(0.0f, roofBase + thatchLayerHeight * 0.5f, 0.0f),
+          QVector3D(layerWidth, thatchLayerHeight / 2, layerDepth), C.thatch);
+
+  unitBox(
+      out, unit, white, p.model,
+      QVector3D(0.0f, roofBase + thatchLayerHeight * 1.8f, 0.0f),
+      QVector3D(layerWidth * 0.85f, thatchLayerHeight / 2, layerDepth * 0.85f),
+      C.thatchDark);
+
+  unitBox(
+      out, unit, white, p.model,
+      QVector3D(0.0f, roofBase + thatchLayerHeight * 3.0f, 0.0f),
+      QVector3D(layerWidth * 0.7f, thatchLayerHeight / 2, layerDepth * 0.7f),
+      C.thatch);
+}
+
+static inline void drawDoor(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                            Texture *white, const BarracksPalette &C) {
+  constexpr float baseDepth = BuildingProportions::baseDepth;
+  constexpr float doorWidth = BuildingProportions::doorWidth;
+  constexpr float doorHeight = BuildingProportions::doorHeight;
+
+  unitBox(out, unit, white, p.model,
+          QVector3D(0.0f, doorHeight / 2, baseDepth / 2 + 0.01f),
+          QVector3D(doorWidth / 2, doorHeight / 2, 0.05f), C.door);
+
+  float trimW = 0.04f;
+  unitBox(out, unit, white, p.model,
+          QVector3D(0.0f, doorHeight + trimW, baseDepth / 2 + 0.01f),
+          QVector3D(doorWidth / 2 + trimW, trimW, 0.02f), C.timber);
+}
+
+static inline void drawWindows(const DrawContext &p, ISubmitter &out,
+                               Mesh *unit, Texture *white,
+                               const BarracksPalette &C) {
+  constexpr float baseWidth = BuildingProportions::baseWidth;
+  constexpr float baseDepth = BuildingProportions::baseDepth;
+  constexpr float baseHeight = BuildingProportions::baseHeight;
+  constexpr float windowWidth = BuildingProportions::windowWidth;
+  constexpr float windowHeight = BuildingProportions::windowHeight;
+
+  unitBox(out, unit, white, p.model,
+          QVector3D(0.0f, baseHeight * 0.6f, -baseDepth / 2 - 0.01f),
+          QVector3D(windowWidth / 2, windowHeight / 2, 0.05f), C.window);
+
+  unitBox(out, unit, white, p.model,
+          QVector3D(-baseWidth / 2 - 0.01f, baseHeight * 0.6f, 0.2f),
+          QVector3D(0.05f, windowHeight / 2 * 0.8f, windowWidth / 2 * 0.7f),
+          C.window);
+  unitBox(out, unit, white, p.model,
+          QVector3D(baseWidth / 2 + 0.01f, baseHeight * 0.6f, -0.2f),
+          QVector3D(0.05f, windowHeight / 2 * 0.8f, windowWidth / 2 * 0.7f),
+          C.window);
+}
+
+static inline void drawAnnex(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                             Texture *white, const BarracksPalette &C) {
+  constexpr float baseWidth = BuildingProportions::baseWidth;
+  constexpr float annexWidth = BuildingProportions::annexWidth;
+  constexpr float annexDepth = BuildingProportions::annexDepth;
+  constexpr float annexHeight = BuildingProportions::annexHeight;
+  constexpr float annexRoofHeight = BuildingProportions::annexRoofHeight;
+  constexpr float cornerPostRadius = BuildingProportions::cornerPostRadius;
+
+  float annexX = baseWidth / 2 + annexWidth / 2 - 0.2f;
+  float annexY = annexHeight / 2;
+
+  unitBox(out, unit, white, p.model, QVector3D(annexX, annexY, -0.3f),
+          QVector3D(annexWidth / 2, annexHeight / 2, annexDepth / 2),
+          C.plasterShade);
+
+  auto annexPost = [&](float ox, float oz) {
+    QVector3D base(annexX + ox, 0.0f, -0.3f + oz);
+    QVector3D top(annexX + ox, annexHeight, -0.3f + oz);
+    drawCylinder(out, p.model, base, top, cornerPostRadius * 0.7f, C.timber,
+                 white);
+  };
+
+  float hw = annexWidth / 2 * 0.8f;
+  float hd = annexDepth / 2 * 0.8f;
+  annexPost(hw, hd);
+  annexPost(-hw, hd);
+  annexPost(hw, -hd);
+  annexPost(-hw, -hd);
+
+  unitBox(out, unit, white, p.model,
+          QVector3D(annexX, annexHeight + annexRoofHeight / 2, -0.3f),
+          QVector3D(annexWidth / 2 + 0.1f, annexRoofHeight / 2,
+                    annexDepth / 2 + 0.1f),
+          C.thatchDark);
+}
+
+static inline void drawChimney(const DrawContext &p, ISubmitter &out,
+                               Mesh *unit, Texture *white,
+                               const BarracksPalette &C) {
+  constexpr float baseWidth = BuildingProportions::baseWidth;
+  constexpr float baseDepth = BuildingProportions::baseDepth;
+  constexpr float baseHeight = BuildingProportions::baseHeight;
+  constexpr float roofPitch = BuildingProportions::roofPitch;
+  constexpr float chimneyWidth = BuildingProportions::chimneyWidth;
+  constexpr float chimneyHeight = BuildingProportions::chimneyHeight;
+  constexpr float chimneyCapSize = BuildingProportions::chimneyCapSize;
+
+  float chimneyX = -baseWidth / 2 + 0.4f;
+  float chimneyZ = -baseDepth / 2 + 0.3f;
+  float chimneyBase = baseHeight + roofPitch * 0.3f;
+
+  unitBox(out, unit, white, p.model,
+          QVector3D(chimneyX, chimneyBase + chimneyHeight / 2, chimneyZ),
+          QVector3D(chimneyWidth / 2, chimneyHeight / 2, chimneyWidth / 2),
+          C.stone);
+
+  unitBox(out, unit, white, p.model,
+          QVector3D(chimneyX, chimneyBase + chimneyHeight + 0.08f, chimneyZ),
+          QVector3D(chimneyCapSize / 2, 0.08f, chimneyCapSize / 2),
+          C.stoneDark);
+}
+
+static inline void drawPavers(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                              Texture *white, const BarracksPalette &C) {
+  constexpr float baseDepth = BuildingProportions::baseDepth;
+  constexpr float foundationHeight = BuildingProportions::foundationHeight;
+
+  auto paver = [&](float ox, float oz, float sx, float sz) {
+    unitBox(out, unit, white, p.model,
+            QVector3D(0.0f, -foundationHeight + 0.02f, baseDepth / 2 + oz),
+            QVector3D(sx, 0.02f, sz), C.path);
+  };
+
+  paver(0.0f, 0.3f, 0.8f, 0.25f);
+  paver(0.0f, 0.6f, 0.6f, 0.2f);
+  paver(0.0f, 0.85f, 0.7f, 0.15f);
+}
+
+static inline void drawCrates(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                              Texture *white, const BarracksPalette &C) {
+  constexpr float baseWidth = BuildingProportions::baseWidth;
+  constexpr float baseDepth = BuildingProportions::baseDepth;
+
+  float crateX = baseWidth / 2 - 0.4f;
+  float crateZ = baseDepth / 2 - 0.3f;
+
+  unitBox(out, unit, white, p.model, QVector3D(crateX, 0.12f, crateZ),
+          QVector3D(0.12f, 0.12f, 0.12f), C.crate);
+  unitBox(out, unit, white, p.model,
+          QVector3D(crateX + 0.28f, 0.10f, crateZ + 0.05f),
+          QVector3D(0.10f, 0.10f, 0.10f), C.crate * 0.9f);
+}
+
+static inline void drawFencePosts(const DrawContext &p, ISubmitter &out,
+                                  Mesh *unit, Texture *white,
+                                  const BarracksPalette &C) {
+  constexpr float baseWidth = BuildingProportions::baseWidth;
+  constexpr float baseDepth = BuildingProportions::baseDepth;
+
+  for (int i = 0; i < 4; ++i) {
+    float x = -baseWidth / 2 - 0.25f;
+    float z = -baseDepth / 2 + 0.3f + i * 0.35f;
+    QVector3D bottom(x, 0.0f, z);
+    QVector3D top(x, 0.6f, z);
+    drawCylinder(out, p.model, bottom, top, 0.04f, C.timber, white);
+  }
 
   for (int i = 0; i < 3; ++i) {
-    QMatrix4x4 post = p.model;
-    post.translate(-0.85f + 0.18f * i, -0.05f, 0.85f);
-    post.scale(0.05f, 0.25f, 0.05f);
-    out.mesh(p.resources->unit(), post, woodDark, p.resources->white(), 1.0f);
+    float x = -baseWidth / 2 - 0.25f;
+    float z1 = -baseDepth / 2 + 0.3f + i * 0.35f;
+    float z2 = z1 + 0.35f;
+    drawCylinder(out, p.model, QVector3D(x, 0.35f, z1), QVector3D(x, 0.35f, z2),
+                 0.025f, C.timberLight, white);
   }
+}
 
-  QMatrix4x4 pole = p.model;
-  pole.translate(-0.9f, 0.55f, -0.55f);
-  pole.scale(0.04f, 0.8f, 0.04f);
-  out.mesh(p.resources->unit(), pole, woodDark, p.resources->white(), 1.0f);
-
-  QMatrix4x4 finial = p.model;
-  finial.translate(-0.9f, 0.98f, -0.55f);
-  finial.scale(0.08f, 0.08f, 0.08f);
-  out.mesh(p.resources->unit(), finial, QVector3D(0.9f, 0.8f, 0.3f),
-           p.resources->white(), 1.0f);
-
-  QMatrix4x4 banner = p.model;
-  banner.translate(-0.78f, 0.75f, -0.53f);
-  banner.scale(0.28f, 0.32f, 0.02f);
-  out.mesh(p.resources->unit(), banner, team, p.resources->white(), 1.0f);
-
-  QMatrix4x4 bannerTrim = p.model;
-  bannerTrim.translate(-0.78f, 0.75f, -0.51f);
-  bannerTrim.scale(0.30f, 0.04f, 0.01f);
-  out.mesh(p.resources->unit(), bannerTrim,
-           QVector3D(team.x() * 0.6f, team.y() * 0.6f, team.z() * 0.6f),
-           p.resources->white(), 1.0f);
+static inline void drawBannerAndPole(const DrawContext &p, ISubmitter &out,
+                                     Mesh *unit, Texture *white,
+                                     const BarracksPalette &C) {
+  constexpr float baseWidth = BuildingProportions::baseWidth;
+  constexpr float baseDepth = BuildingProportions::baseDepth;
+  constexpr float bannerPoleHeight = BuildingProportions::bannerPoleHeight;
+  constexpr float bannerPoleRadius = BuildingProportions::bannerPoleRadius;
+  constexpr float bannerWidth = BuildingProportions::bannerWidth;
+  constexpr float bannerHeight = BuildingProportions::bannerHeight;
+
+  float poleX = -baseWidth / 2 - 0.35f;
+  float poleZ = baseDepth / 2 - 0.4f;
+
+  drawCylinder(out, p.model, QVector3D(poleX, 0.0f, poleZ),
+               QVector3D(poleX, bannerPoleHeight, poleZ), bannerPoleRadius,
+               C.timber, white);
+
+  drawSphere(out, p.model, QVector3D(poleX, bannerPoleHeight + 0.08f, poleZ),
+             0.08f, C.teamTrim, white);
+
+  unitBox(out, unit, white, p.model,
+          QVector3D(poleX + bannerWidth / 2,
+                    bannerPoleHeight - bannerHeight / 2, poleZ),
+          QVector3D(bannerWidth / 2, bannerHeight / 2, 0.02f), C.team);
+
+  unitBox(out, unit, white, p.model,
+          QVector3D(poleX + bannerWidth / 2,
+                    bannerPoleHeight - bannerHeight + 0.04f, poleZ),
+          QVector3D(bannerWidth / 2 + 0.02f, 0.04f, 0.015f), C.teamTrim);
+  unitBox(out, unit, white, p.model,
+          QVector3D(poleX + bannerWidth / 2, bannerPoleHeight - 0.04f, poleZ),
+          QVector3D(bannerWidth / 2 + 0.02f, 0.04f, 0.015f), C.teamTrim);
+}
 
+static inline void drawRallyFlagIfAny(const DrawContext &p, ISubmitter &out,
+                                      Texture *white,
+                                      const BarracksPalette &C) {
   if (auto *prod =
           p.entity->getComponent<Engine::Core::ProductionComponent>()) {
     if (prod->rallySet && p.resources) {
       auto flag = Render::Geom::Flag::create(prod->rallyX, prod->rallyZ,
                                              QVector3D(1.0f, 0.9f, 0.2f),
-                                             woodDark, 1.0f);
-
-      out.mesh(p.resources->unit(), flag.pole, flag.poleColor,
-               p.resources->white(), 1.0f);
-      out.mesh(p.resources->unit(), flag.pennant, flag.pennantColor,
-               p.resources->white(), 1.0f);
-      out.mesh(p.resources->unit(), flag.finial, flag.pennantColor,
-               p.resources->white(), 1.0f);
+                                             C.woodDark, 1.0f);
+      Mesh *unit = p.resources->unit();
+      out.mesh(unit, flag.pole, flag.poleColor, white, 1.0f);
+      out.mesh(unit, flag.pennant, flag.pennantColor, white, 1.0f);
+      out.mesh(unit, flag.finial, flag.pennantColor, white, 1.0f);
     }
   }
+}
 
-  if (u && u->maxHealth > 0) {
-    float healthRatio = float(u->health) / float(u->maxHealth);
-    QVector3D pos = p.model.column(3).toVector3D();
-
-    QMatrix4x4 bgBar;
-    bgBar.translate(pos.x(), 1.2f, pos.z());
-    bgBar.scale(1.5f, 0.08f, 0.04f);
-    out.mesh(p.resources->unit(), bgBar, QVector3D(0.3f, 0.1f, 0.1f),
-             p.resources->white(), 1.0f);
-
-    QVector3D healthColor;
-    if (healthRatio > 0.6f) {
-      healthColor = QVector3D(0.2f, 0.8f, 0.2f);
-    } else if (healthRatio > 0.3f) {
-      healthColor = QVector3D(0.9f, 0.7f, 0.2f);
-    } else {
-      healthColor = QVector3D(0.9f, 0.2f, 0.2f);
-    }
-
-    QMatrix4x4 fgBar;
-    fgBar.translate(pos.x() - 0.75f + (0.75f * healthRatio), 1.21f, pos.z());
-    fgBar.scale(1.5f * healthRatio, 0.06f, 0.03f);
-    out.mesh(p.resources->unit(), fgBar, healthColor, p.resources->white(),
-             1.0f);
-  }
+static inline void drawSelectionFX(const DrawContext &p, ISubmitter &out) {
+  QMatrix4x4 M;
+  QVector3D pos = p.model.column(3).toVector3D();
+  M.translate(pos.x(), 0.0f, pos.z());
+  M.scale(2.2f, 1.0f, 2.0f);
+  if (p.selected)
+    out.selectionSmoke(M, QVector3D(0.2f, 0.85f, 0.2f), 0.35f);
+  else if (p.hovered)
+    out.selectionSmoke(M, QVector3D(0.95f, 0.92f, 0.25f), 0.22f);
+}
 
-  if (p.selected) {
-    QMatrix4x4 m;
-    QVector3D pos = p.model.column(3).toVector3D();
+static void drawBarracks(const DrawContext &p, ISubmitter &out) {
+  if (!p.resources || !p.entity)
+    return;
 
-    m.translate(pos.x(), 0.0f, pos.z());
-    m.scale(1.8f, 1.0f, 1.8f);
-    out.selectionSmoke(m, QVector3D(0.2f, 0.8f, 0.2f), 0.32f);
-  }
+  auto *t = p.entity->getComponent<Engine::Core::TransformComponent>();
+  auto *r = p.entity->getComponent<Engine::Core::RenderableComponent>();
+  if (!t || !r)
+    return;
 
-  else if (p.hovered) {
-    QMatrix4x4 m;
-    QVector3D pos = p.model.column(3).toVector3D();
-    m.translate(pos.x(), 0.0f, pos.z());
-    m.scale(1.8f, 1.0f, 1.8f);
-    out.selectionSmoke(m, QVector3D(0.90f, 0.90f, 0.25f), 0.20f);
-  }
+  Mesh *unit = p.resources->unit();
+  Texture *white = p.resources->white();
+
+  QVector3D team(r->color[0], r->color[1], r->color[2]);
+  BarracksPalette C = makePalette(team);
+
+  drawFoundation(p, out, unit, white, C);
+  drawTimberFrame(p, out, unit, white, C);
+  drawWalls(p, out, unit, white, C);
+  drawRoofs(p, out, unit, white, C);
+  drawDoor(p, out, unit, white, C);
+  drawWindows(p, out, unit, white, C);
+  drawAnnex(p, out, unit, white, C);
+  drawChimney(p, out, unit, white, C);
+  drawPavers(p, out, unit, white, C);
+  drawCrates(p, out, unit, white, C);
+  drawFencePosts(p, out, unit, white, C);
+  drawBannerAndPole(p, out, unit, white, C);
+
+  drawRallyFlagIfAny(p, out, white, C);
+  drawSelectionFX(p, out);
 }
 
-void registerBarracksRenderer(EntityRendererRegistry &registry) {
+} // namespace
+
+void registerBarracksRenderer(Render::GL::EntityRendererRegistry &registry) {
   registry.registerRenderer("barracks", drawBarracks);
 }
 

+ 4 - 1
render/entity/registry.h

@@ -11,7 +11,8 @@
 namespace Engine {
 namespace Core {
 class Entity;
-}
+class World;
+} // namespace Core
 } // namespace Engine
 namespace Render {
 namespace GL {
@@ -26,9 +27,11 @@ namespace Render::GL {
 struct DrawContext {
   ResourceManager *resources = nullptr;
   Engine::Core::Entity *entity = nullptr;
+  Engine::Core::World *world = nullptr;
   QMatrix4x4 model;
   bool selected = false;
   bool hovered = false;
+  float animationTime = 0.0f;
 };
 
 using RenderFunc = std::function<void(const DrawContext &, ISubmitter &out)>;

+ 5 - 3
render/geom/flag.cpp

@@ -11,15 +11,17 @@ Flag::FlagMatrices Flag::create(float worldX, float worldZ,
   result.poleColor = poleColor;
 
   result.pole.setToIdentity();
-  result.pole.translate(worldX, 0.15f * scale, worldZ);
+
+  result.pole.translate(worldX, (0.15f + 0.02f) * scale, worldZ);
   result.pole.scale(0.03f * scale, 0.30f * scale, 0.03f * scale);
 
   result.pennant.setToIdentity();
-  result.pennant.translate(worldX + 0.10f * scale, 0.25f * scale, worldZ);
+  result.pennant.translate(worldX + 0.10f * scale, (0.25f + 0.02f) * scale,
+                           worldZ);
   result.pennant.scale(0.18f * scale, 0.12f * scale, 0.02f * scale);
 
   result.finial.setToIdentity();
-  result.finial.translate(worldX, 0.32f * scale, worldZ);
+  result.finial.translate(worldX, (0.32f + 0.02f) * scale, worldZ);
   result.finial.scale(0.05f * scale, 0.05f * scale, 0.05f * scale);
 
   return result;

+ 23 - 0
render/geom/math_utils.h

@@ -0,0 +1,23 @@
+#pragma once
+
+#include <QVector3D>
+#include <algorithm>
+
+namespace Render::Geom {
+
+inline float clamp01(float x) { return std::max(0.0f, std::min(1.0f, x)); }
+
+inline float clampf(float x, float minVal, float maxVal) {
+  return std::max(minVal, std::min(maxVal, x));
+}
+
+inline QVector3D clampVec01(const QVector3D &c) {
+  return QVector3D(clamp01(c.x()), clamp01(c.y()), clamp01(c.z()));
+}
+
+inline QVector3D clampVec(const QVector3D &c, float minVal, float maxVal) {
+  return QVector3D(clampf(c.x(), minVal, maxVal), clampf(c.y(), minVal, maxVal),
+                   clampf(c.z(), minVal, maxVal));
+}
+
+} // namespace Render::Geom

+ 51 - 0
render/geom/transforms.cpp

@@ -0,0 +1,51 @@
+#include "transforms.h"
+#include <algorithm>
+#include <cmath>
+
+namespace Render::Geom {
+
+QMatrix4x4 cylinderBetween(const QVector3D &a, const QVector3D &b,
+                           float radius) {
+  QVector3D mid = (a + b) * 0.5f;
+  QVector3D dir = b - a;
+  float len = dir.length();
+
+  QMatrix4x4 M;
+  M.translate(mid);
+
+  if (len > 1e-6f) {
+    QVector3D yAxis(0, 1, 0);
+    QVector3D d = dir / len;
+    float dot = std::clamp(QVector3D::dotProduct(yAxis, d), -1.0f, 1.0f);
+    float angleDeg = std::acos(dot) * 57.2957795131f;
+    QVector3D axis = QVector3D::crossProduct(yAxis, d);
+    if (axis.lengthSquared() < 1e-6f) {
+
+      if (dot < 0.0f) {
+        M.rotate(180.0f, 1.0f, 0.0f, 0.0f);
+      }
+    } else {
+      axis.normalize();
+      M.rotate(angleDeg, axis);
+    }
+    M.scale(radius, len, radius);
+  } else {
+    M.scale(radius, 1.0f, radius);
+  }
+  return M;
+}
+
+QMatrix4x4 sphereAt(const QVector3D &pos, float radius) {
+  QMatrix4x4 M;
+  M.translate(pos);
+  M.scale(radius, radius, radius);
+  return M;
+}
+
+QMatrix4x4 coneFromTo(const QVector3D &baseCenter, const QVector3D &apex,
+                      float baseRadius) {
+
+  return cylinderBetween(baseCenter, apex, baseRadius);
+}
+
+} // namespace Render::Geom

+ 16 - 0
render/geom/transforms.h

@@ -0,0 +1,16 @@
+#pragma once
+
+#include <QMatrix4x4>
+#include <QVector3D>
+
+namespace Render::Geom {
+
+QMatrix4x4 cylinderBetween(const QVector3D &a, const QVector3D &b,
+                           float radius);
+
+QMatrix4x4 sphereAt(const QVector3D &pos, float radius);
+
+QMatrix4x4 coneFromTo(const QVector3D &baseCenter, const QVector3D &apex,
+                      float baseRadius);
+
+} // namespace Render::Geom

+ 10 - 0
render/gl/backend.cpp

@@ -13,8 +13,12 @@ Backend::~Backend() = default;
 
 void Backend::initialize() {
   initializeOpenGLFunctions();
+
   glEnable(GL_DEPTH_TEST);
   glDepthFunc(GL_LESS);
+  glDepthRange(0.0, 1.0);
+  glDepthMask(GL_TRUE);
+
   glEnable(GL_BLEND);
   glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
 
@@ -38,7 +42,13 @@ void Backend::beginFrame() {
   }
   glClearColor(m_clearColor[0], m_clearColor[1], m_clearColor[2],
                m_clearColor[3]);
+
+  glClearDepth(1.0);
   glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
+
+  glEnable(GL_DEPTH_TEST);
+  glDepthFunc(GL_LESS);
+  glDepthMask(GL_TRUE);
 }
 
 void Backend::setViewport(int w, int h) {

+ 3 - 2
render/gl/camera.h

@@ -68,8 +68,9 @@ private:
   bool m_isPerspective = true;
   float m_fov = 45.0f;
   float m_aspect = 16.0f / 9.0f;
-  float m_nearPlane = 0.1f;
-  float m_farPlane = 1000.0f;
+
+  float m_nearPlane = 1.0f;
+  float m_farPlane = 200.0f;
 
   float m_orthoLeft = -10.0f;
   float m_orthoRight = 10.0f;

+ 271 - 0
render/gl/primitives.cpp

@@ -0,0 +1,271 @@
+#include "primitives.h"
+#include <QVector3D>
+#include <cmath>
+#include <memory>
+#include <vector>
+
+namespace Render::GL {
+
+namespace {
+
+Mesh *createUnitCylinderMesh(int radialSegments) {
+  const float radius = 1.0f;
+  const float halfH = 0.5f;
+
+  std::vector<Vertex> v;
+  std::vector<unsigned int> idx;
+
+  for (int y = 0; y <= 1; ++y) {
+    float py = y ? halfH : -halfH;
+    float vCoord = float(y);
+    for (int i = 0; i <= radialSegments; ++i) {
+      float u = float(i) / float(radialSegments);
+      float ang = u * 6.28318530718f;
+      float px = radius * std::cos(ang);
+      float pz = radius * std::sin(ang);
+      QVector3D n(px, 0.0f, pz);
+      n.normalize();
+      v.push_back({{px, py, pz}, {n.x(), n.y(), n.z()}, {u, vCoord}});
+    }
+  }
+  int row = radialSegments + 1;
+  for (int i = 0; i < radialSegments; ++i) {
+    int a = 0 * row + i;
+    int b = 0 * row + i + 1;
+    int c = 1 * row + i + 1;
+    int d = 1 * row + 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);
+  }
+
+  int baseTop = (int)v.size();
+  v.push_back({{0.0f, halfH, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.5f, 0.5f}});
+  for (int i = 0; i <= radialSegments; ++i) {
+    float u = float(i) / float(radialSegments);
+    float ang = u * 6.28318530718f;
+    float px = radius * std::cos(ang);
+    float pz = radius * std::sin(ang);
+    v.push_back({{px, halfH, pz},
+                 {0.0f, 1.0f, 0.0f},
+                 {0.5f + 0.5f * std::cos(ang), 0.5f + 0.5f * std::sin(ang)}});
+  }
+  for (int i = 1; i <= radialSegments; ++i) {
+    idx.push_back(baseTop);
+    idx.push_back(baseTop + i);
+    idx.push_back(baseTop + i + 1);
+  }
+
+  int baseBot = (int)v.size();
+  v.push_back({{0.0f, -halfH, 0.0f}, {0.0f, -1.0f, 0.0f}, {0.5f, 0.5f}});
+  for (int i = 0; i <= radialSegments; ++i) {
+    float u = float(i) / float(radialSegments);
+    float ang = u * 6.28318530718f;
+    float px = radius * std::cos(ang);
+    float pz = radius * std::sin(ang);
+    v.push_back({{px, -halfH, pz},
+                 {0.0f, -1.0f, 0.0f},
+                 {0.5f + 0.5f * std::cos(ang), 0.5f + 0.5f * std::sin(ang)}});
+  }
+  for (int i = 1; i <= radialSegments; ++i) {
+    idx.push_back(baseBot);
+    idx.push_back(baseBot + i + 1);
+    idx.push_back(baseBot + i);
+  }
+
+  return new Mesh(v, idx);
+}
+
+Mesh *createUnitSphereMesh(int latSegments, int lonSegments) {
+  const float r = 1.0f;
+  std::vector<Vertex> v;
+  std::vector<unsigned int> idx;
+
+  for (int y = 0; y <= latSegments; ++y) {
+    float vy = float(y) / float(latSegments);
+    float phi = vy * 3.14159265358979323846f;
+    float py = r * std::cos(phi);
+    float pr = r * std::sin(phi);
+
+    for (int x = 0; x <= lonSegments; ++x) {
+      float vx = float(x) / float(lonSegments);
+      float theta = vx * 6.28318530717958647692f;
+      float px = pr * std::cos(theta);
+      float pz = pr * std::sin(theta);
+
+      QVector3D n(px, py, pz);
+      n.normalize();
+      v.push_back({{px, py, pz}, {n.x(), n.y(), n.z()}, {vx, vy}});
+    }
+  }
+
+  int row = lonSegments + 1;
+  for (int y = 0; y < latSegments; ++y) {
+    for (int x = 0; x < lonSegments; ++x) {
+      int a = y * row + x;
+      int b = a + 1;
+      int c = (y + 1) * row + x + 1;
+      int d = (y + 1) * row + x;
+      idx.push_back(a);
+      idx.push_back(b);
+      idx.push_back(c);
+      idx.push_back(c);
+      idx.push_back(d);
+      idx.push_back(a);
+    }
+  }
+
+  return new Mesh(v, idx);
+}
+
+Mesh *createUnitConeMesh(int radialSegments) {
+  const float baseR = 1.0f;
+  const float halfH = 0.5f;
+
+  std::vector<Vertex> v;
+  std::vector<unsigned int> idx;
+
+  int apexIdx = 0;
+  v.push_back({{0.0f, +halfH, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.5f, 1.0f}});
+
+  for (int i = 0; i <= radialSegments; ++i) {
+    float u = float(i) / float(radialSegments);
+    float ang = u * 6.28318530718f;
+    float px = baseR * std::cos(ang);
+    float pz = baseR * std::sin(ang);
+    QVector3D n(px, baseR, pz);
+    n.normalize();
+    v.push_back({{px, -halfH, pz}, {n.x(), n.y(), n.z()}, {u, 0.0f}});
+  }
+
+  for (int i = 1; i <= radialSegments; ++i) {
+    idx.push_back(apexIdx);
+    idx.push_back(i);
+    idx.push_back(i + 1);
+  }
+
+  int baseCenter = (int)v.size();
+  v.push_back({{0.0f, -halfH, 0.0f}, {0.0f, -1.0f, 0.0f}, {0.5f, 0.5f}});
+  int baseStart = (int)v.size();
+  for (int i = 0; i <= radialSegments; ++i) {
+    float u = float(i) / float(radialSegments);
+    float ang = u * 6.28318530718f;
+    float px = baseR * std::cos(ang);
+    float pz = baseR * std::sin(ang);
+    v.push_back({{px, -halfH, pz},
+                 {0.0f, -1.0f, 0.0f},
+                 {0.5f + 0.5f * std::cos(ang), 0.5f + 0.5f * std::sin(ang)}});
+  }
+  for (int i = 0; i < radialSegments; ++i) {
+    idx.push_back(baseCenter);
+    idx.push_back(baseStart + i + 1);
+    idx.push_back(baseStart + i);
+  }
+
+  return new Mesh(v, idx);
+}
+
+Mesh *createCapsuleMesh(int radialSegments, int heightSegments) {
+  const float radius = 0.25f;
+  const float halfH = 0.5f;
+
+  std::vector<Vertex> verts;
+  std::vector<unsigned int> idx;
+
+  for (int y = 0; y <= heightSegments; ++y) {
+    float v = float(y) / float(heightSegments);
+    float py = -halfH + v * (2.0f * halfH);
+    for (int i = 0; i <= radialSegments; ++i) {
+      float u = float(i) / float(radialSegments);
+      float ang = u * 6.2831853f;
+      float px = radius * std::cos(ang);
+      float pz = radius * std::sin(ang);
+      QVector3D n(px, 0.0f, pz);
+      n.normalize();
+      verts.push_back({{px, py, pz}, {n.x(), n.y(), n.z()}, {u, v}});
+    }
+  }
+
+  int row = radialSegments + 1;
+  for (int y = 0; y < heightSegments; ++y) {
+    for (int i = 0; i < radialSegments; ++i) {
+      int a = y * row + i;
+      int b = y * row + i + 1;
+      int c = (y + 1) * row + i + 1;
+      int d = (y + 1) * row + 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);
+    }
+  }
+
+  int baseTop = (int)verts.size();
+  verts.push_back({{0.0f, halfH, 0.0f}, {0.0f, 1.0f, 0.0f}, {0.5f, 0.5f}});
+  for (int i = 0; i <= radialSegments; ++i) {
+    float u = float(i) / float(radialSegments);
+    float ang = u * 6.2831853f;
+    float px = radius * std::cos(ang);
+    float pz = radius * std::sin(ang);
+    verts.push_back(
+        {{px, halfH, pz},
+         {0.0f, 1.0f, 0.0f},
+         {0.5f + 0.5f * std::cos(ang), 0.5f + 0.5f * std::sin(ang)}});
+  }
+  for (int i = 1; i <= radialSegments; ++i) {
+    idx.push_back(baseTop);
+    idx.push_back(baseTop + i);
+    idx.push_back(baseTop + i + 1);
+  }
+
+  int baseBot = (int)verts.size();
+  verts.push_back({{0.0f, -halfH, 0.0f}, {0.0f, -1.0f, 0.0f}, {0.5f, 0.5f}});
+  for (int i = 0; i <= radialSegments; ++i) {
+    float u = float(i) / float(radialSegments);
+    float ang = u * 6.2831853f;
+    float px = radius * std::cos(ang);
+    float pz = radius * std::sin(ang);
+    verts.push_back(
+        {{px, -halfH, pz},
+         {0.0f, -1.0f, 0.0f},
+         {0.5f + 0.5f * std::cos(ang), 0.5f + 0.5f * std::sin(ang)}});
+  }
+  for (int i = 1; i <= radialSegments; ++i) {
+    idx.push_back(baseBot);
+    idx.push_back(baseBot + i + 1);
+    idx.push_back(baseBot + i);
+  }
+
+  return new Mesh(verts, idx);
+}
+
+} // namespace
+
+Mesh *getUnitCylinder(int radialSegments) {
+  static std::unique_ptr<Mesh> s_mesh(createUnitCylinderMesh(radialSegments));
+  return s_mesh.get();
+}
+
+Mesh *getUnitSphere(int latSegments, int lonSegments) {
+  static std::unique_ptr<Mesh> s_mesh(
+      createUnitSphereMesh(latSegments, lonSegments));
+  return s_mesh.get();
+}
+
+Mesh *getUnitCone(int radialSegments) {
+  static std::unique_ptr<Mesh> s_mesh(createUnitConeMesh(radialSegments));
+  return s_mesh.get();
+}
+
+Mesh *getUnitCapsule(int radialSegments, int heightSegments) {
+  static std::unique_ptr<Mesh> s_mesh(
+      createCapsuleMesh(radialSegments, heightSegments));
+  return s_mesh.get();
+}
+
+} // namespace Render::GL

+ 16 - 0
render/gl/primitives.h

@@ -0,0 +1,16 @@
+#pragma once
+
+#include "mesh.h"
+#include <memory>
+
+namespace Render::GL {
+
+Mesh *getUnitCylinder(int radialSegments = 32);
+
+Mesh *getUnitSphere(int latSegments = 16, int lonSegments = 32);
+
+Mesh *getUnitCone(int radialSegments = 32);
+
+Mesh *getUnitCapsule(int radialSegments = 32, int heightSegments = 1);
+
+} // namespace Render::GL

+ 2 - 1
render/scene_renderer.cpp

@@ -129,11 +129,12 @@ void Renderer::renderWorld(Engine::Core::World *world) {
       if (!unit->unitType.empty() && m_entityRegistry) {
         auto fn = m_entityRegistry->get(unit->unitType);
         if (fn) {
-          DrawContext ctx{resources(), entity, modelMatrix};
+          DrawContext ctx{resources(), entity, world, modelMatrix};
 
           ctx.selected =
               (m_selectedIds.find(entity->getId()) != m_selectedIds.end());
           ctx.hovered = (entity->getId() == m_hoveredBuildingId);
+          ctx.animationTime = m_accumulatedTime;
           fn(ctx, *this);
           drawnByRegistry = true;
         }

+ 4 - 0
render/scene_renderer.h

@@ -46,6 +46,9 @@ public:
   void setCamera(Camera *camera);
   void setClearColor(float r, float g, float b, float a = 1.0f);
 
+  void updateAnimationTime(float deltaTime) { m_accumulatedTime += deltaTime; }
+  float getAnimationTime() const { return m_accumulatedTime; }
+
   ResourceManager *resources() const {
     return m_backend ? m_backend->resources() : nullptr;
   }
@@ -118,6 +121,7 @@ private:
   int m_viewportWidth = 0;
   int m_viewportHeight = 0;
   GridParams m_gridParams;
+  float m_accumulatedTime = 0.0f;
 };
 
 } // namespace Render::GL

+ 41 - 1
ui/qml/HUD.qml

@@ -19,11 +19,39 @@ Item {
     property int bottomPanelHeight: bottomPanel.height
     
     property int selectionTick: 0
+    property bool hasMovableUnits: false
 
     Connections {
         target: (typeof game !== 'undefined') ? game : null
         function onSelectedUnitsChanged() { 
             selectionTick += 1
+            
+            
+            var hasTroops = false
+            if (typeof game !== 'undefined' && game.hasUnitsSelected && game.hasSelectedType) {
+                
+                var troopTypes = ["warrior", "archer"]
+                for (var i = 0; i < troopTypes.length; i++) {
+                    if (game.hasSelectedType(troopTypes[i])) {
+                        hasTroops = true
+                        break
+                    }
+                }
+            }
+            
+            
+            var actualMode = "normal"
+            if (hasTroops && typeof game !== 'undefined' && game.getSelectedUnitsCommandMode) {
+                actualMode = game.getSelectedUnitsCommandMode()
+            }
+            
+            
+            if (currentCommandMode !== actualMode) {
+                currentCommandMode = actualMode
+                commandModeChanged(actualMode)
+            }
+            
+            hasMovableUnits = hasTroops
         }
     }
 
@@ -33,7 +61,18 @@ Item {
         interval: 100
         repeat: true
         running: true
-        onTriggered: selectionTick += 1
+        onTriggered: {
+            selectionTick += 1
+            
+            
+            if (hasMovableUnits && typeof game !== 'undefined' && game.getSelectedUnitsCommandMode) {
+                var actualMode = game.getSelectedUnitsCommandMode()
+                if (currentCommandMode !== actualMode) {
+                    currentCommandMode = actualMode
+                    
+                }
+            }
+        }
     }
     
     
@@ -65,6 +104,7 @@ Item {
             anchors.fill: parent
             currentCommandMode: hud.currentCommandMode
             selectionTick: hud.selectionTick
+            hasMovableUnits: hud.hasMovableUnits
             onCommandModeChanged: function(m) { hud.currentCommandMode = m; hud.commandModeChanged(m); }
             onRecruit: function(t) { hud.recruit(t); }
         }

+ 211 - 192
ui/qml/HUDBottom.qml

@@ -2,241 +2,260 @@ import QtQuick 2.15
 import QtQuick.Controls 2.15
 import QtQuick.Layouts 2.15
 
-Item {
+RowLayout {
     id: bottomRoot
+    
+    
+    property string currentCommandMode
+    property int selectionTick
+    property bool hasMovableUnits
+    
+    
     signal commandModeChanged(string mode)
     signal recruit(string unitType)
-    property string currentCommandMode: "normal"
-    property int selectionTick: 0
+    
+    anchors.fill: parent
+    anchors.margins: 10
+    spacing: 12
 
+    
     Rectangle {
-        id: bottomPanel
-        anchors.bottom: parent.bottom
-        anchors.left: parent.left
-        anchors.right: parent.right
-        height: Math.max(140, parent.height * 0.20)
-        color: "#1a1a1a"
-        opacity: 0.95
-
-        
-        Rectangle { anchors.fill: parent; gradient: Gradient { GradientStop { position: 0.0; color: "#1a252f" } GradientStop { position: 1.0; color: "#2c3e50" } } opacity: 0.8 }
-
-        
-        Rectangle { anchors.left: parent.left; anchors.right: parent.right; anchors.top: parent.top; height: 2; gradient: Gradient { GradientStop { position: 0.0; color: "transparent" } GradientStop { position: 0.5; color: "#3498db" } GradientStop { position: 1.0; color: "transparent" } } }
-
-        RowLayout {
+        Layout.fillWidth: true
+        Layout.preferredWidth: Math.max(240, bottomPanel.width * 0.30)
+        Layout.fillHeight: true
+        Layout.alignment: Qt.AlignTop
+        color: "#0f1419"
+        border.color: "#3498db"
+        border.width: 2
+        radius: 6
+
+        Column {
             anchors.fill: parent
-            anchors.margins: 10
-            spacing: 12
+            anchors.margins: 6
+            spacing: 6
 
-            
-            Rectangle {
-                Layout.preferredWidth: Math.max(200, parent.width * 0.18)
-                Layout.fillHeight: true
-                color: "#0f1419"
-                border.color: "#3498db"
-                border.width: 2
-                radius: 6
-
-                Column {
-                    anchors.fill: parent
-                    anchors.margins: 6
-                    spacing: 6
+            Rectangle { width: parent.width; height: 25; color: "#1a252f"; radius: 4
+                Text { anchors.centerIn: parent; text: "SELECTED UNITS"; color: "#3498db"; font.pointSize: 10; font.bold: true }
+            }
 
-                    Rectangle { width: parent.width; height: 25; color: "#1a252f"; radius: 4
-                        Text { anchors.centerIn: parent; text: "SELECTED UNITS"; color: "#3498db"; font.pointSize: 10; font.bold: true }
-                    }
+            ScrollView {
+                anchors.left: parent.left
+                anchors.right: parent.right
+                anchors.top: undefined
+                anchors.bottom: parent.bottom
+                height: parent.height - 35
+                clip: true
+                ScrollBar.vertical.policy: ScrollBar.AsNeeded
+
+                ListView {
+                    id: selectedUnitsList
+                    model: (typeof game !== 'undefined' && game.selectedUnitsModel) ? game.selectedUnitsModel : null
+                    boundsBehavior: Flickable.StopAtBounds
+                    flickableDirection: Flickable.VerticalFlick
+                    spacing: 3
+
+                    delegate: Rectangle {
+                        width: selectedUnitsList.width - 10
+                        height: 28
+                        color: "#1a252f"
+                        radius: 4
+                        border.color: "#34495e"
+                        border.width: 1
+
+                        Row {
+                            anchors.left: parent.left
+                            anchors.verticalCenter: parent.verticalCenter
+                            anchors.margins: 6
+                            spacing: 8
+
+                            Text {
+                                text: (typeof name !== 'undefined') ? name : "Unit"
+                                color: "#ecf0f1"
+                                font.pointSize: 9
+                                font.bold: true
+                                width: 80
+                            }
 
-                    ScrollView { width: parent.width; height: parent.height - 35; clip: true; ScrollBar.vertical.policy: ScrollBar.AsNeeded
-                        ListView {
-                            id: selectedUnitsList
-                            model: (typeof game !== 'undefined' && game.selectedUnitsModel) ? game.selectedUnitsModel : null
-                            boundsBehavior: Flickable.StopAtBounds
-                            flickableDirection: Flickable.VerticalFlick
-                            spacing: 3
-
-                            delegate: Rectangle {
-                                width: selectedUnitsList.width - 10
-                                height: 28
-                                color: "#1a252f"
-                                radius: 4
-                                border.color: "#34495e"
+                            Rectangle {
+                                width: 60
+                                height: 12
+                                color: "#2c3e50"
+                                radius: 6
+                                border.color: "#1a252f"
                                 border.width: 1
 
-                                Row {
-                                    anchors.left: parent.left
-                                    anchors.verticalCenter: parent.verticalCenter
-                                    anchors.margins: 6
-                                    spacing: 8
-
-                                    Text {
-                                        text: (typeof name !== 'undefined') ? name : "Unit"
-                                        color: "#ecf0f1"
-                                        font.pointSize: 9
-                                        font.bold: true
-                                        width: 80
-                                    }
-
-                                    Rectangle {
-                                        width: 60
-                                        height: 12
-                                        color: "#2c3e50"
-                                        radius: 6
-                                        border.color: "#1a252f"
-                                        border.width: 1
-
-                                        Rectangle {
-                                            width: parent.width * (typeof healthRatio !== 'undefined' ? healthRatio : 0)
-                                            height: parent.height
-                                            color: {
-                                                var ratio = (typeof healthRatio !== 'undefined' ? healthRatio : 0)
-                                                if (ratio > 0.6) return "#27ae60"
-                                                if (ratio > 0.3) return "#f39c12"
-                                                return "#e74c3c"
-                                            }
-                                            radius: 6
-                                        }
+                                Rectangle {
+                                    width: parent.width * (typeof healthRatio !== 'undefined' ? healthRatio : 0)
+                                    height: parent.height
+                                    color: {
+                                        var ratio = (typeof healthRatio !== 'undefined' ? healthRatio : 0)
+                                        if (ratio > 0.6) return "#27ae60"
+                                        if (ratio > 0.3) return "#f39c12"
+                                        return "#e74c3c"
                                     }
+                                    radius: 6
                                 }
                             }
                         }
                     }
                 }
             }
+        }
+    }
 
-            
-            Column { Layout.preferredWidth: Math.max(260, parent.width * 0.22); Layout.fillHeight: true; spacing: 8
-                Rectangle { width: parent.width; height: 36; color: bottomRoot.currentCommandMode === "normal" ? "#0f1419" : (bottomRoot.currentCommandMode === "attack" ? "#8b1a1a" : "#1a252f"); border.color: bottomRoot.currentCommandMode === "normal" ? "#34495e" : (bottomRoot.currentCommandMode === "attack" ? "#e74c3c" : "#3498db"); border.width: 2; radius: 6
-                    SequentialAnimation on opacity { running: bottomRoot.currentCommandMode === "attack"; loops: Animation.Infinite; NumberAnimation { from: 0.8; to: 1.0; duration: 600 } NumberAnimation { from: 1.0; to: 0.8; duration: 600 } }
-                    Rectangle { anchors.fill: parent; anchors.margins: -4; color: "transparent"; border.color: bottomRoot.currentCommandMode === "attack" ? "#e74c3c" : "#3498db"; border.width: bottomRoot.currentCommandMode !== "normal" ? 1 : 0; radius: 8; opacity: 0.4; visible: bottomRoot.currentCommandMode !== "normal" }
-                    Text { anchors.centerIn: parent; text: bottomRoot.currentCommandMode === "normal" ? "◉ Normal Mode" : bottomRoot.currentCommandMode === "attack" ? "⚔️ ATTACK MODE - Click Enemy" : bottomRoot.currentCommandMode === "guard" ? "🛡️ GUARD MODE - Click Position" : bottomRoot.currentCommandMode === "patrol" ? "🚶 PATROL MODE - Set Waypoints" : "⏹️ STOP COMMAND"; color: bottomRoot.currentCommandMode === "normal" ? "#7f8c8d" : (bottomRoot.currentCommandMode === "attack" ? "#ff6b6b" : "#3498db"); font.pointSize: bottomRoot.currentCommandMode === "normal" ? 10 : 11; font.bold: bottomRoot.currentCommandMode !== "normal" }
-                }
+    
+    Column {
+        Layout.fillWidth: true
+        Layout.preferredWidth: Math.max(320, bottomPanel.width * 0.40)
+        Layout.fillHeight: true
+        Layout.alignment: Qt.AlignTop
+        spacing: 8
+
+        Rectangle {
+            width: parent.width
+            height: 36
+            color: bottomRoot.currentCommandMode === "normal" ? "#0f1419" : (bottomRoot.currentCommandMode === "attack" ? "#8b1a1a" : "#1a252f")
+            border.color: bottomRoot.currentCommandMode === "normal" ? "#34495e" : (bottomRoot.currentCommandMode === "attack" ? "#e74c3c" : "#3498db")
+            border.width: 2
+            radius: 6
+            opacity: bottomRoot.hasMovableUnits ? 1.0 : 0.5
+
+            SequentialAnimation on opacity {
+                running: bottomRoot.currentCommandMode === "attack" && bottomRoot.hasMovableUnits
+                loops: Animation.Infinite
+                NumberAnimation { from: 0.8; to: 1.0; duration: 600 }
+                NumberAnimation { from: 1.0; to: 0.8; duration: 600 }
+            }
 
-                GridLayout { width: parent.width; columns: 3; rowSpacing: 6; columnSpacing: 6
+            Rectangle { anchors.fill: parent; anchors.margins: -4; color: "transparent"; border.color: bottomRoot.currentCommandMode === "attack" ? "#e74c3c" : "#3498db"; border.width: bottomRoot.currentCommandMode !== "normal" && bottomRoot.hasMovableUnits ? 1 : 0; radius: 8; opacity: 0.4; visible: bottomRoot.currentCommandMode !== "normal" && bottomRoot.hasMovableUnits }
+            Text { 
+                anchors.centerIn: parent
+                text: !bottomRoot.hasMovableUnits ? "◉ Select Troops for Commands" : (bottomRoot.currentCommandMode === "normal" ? "◉ Normal Mode" : bottomRoot.currentCommandMode === "attack" ? "⚔️ ATTACK MODE - Click Enemy" : bottomRoot.currentCommandMode === "guard" ? "🛡️ GUARD MODE - Click Position" : bottomRoot.currentCommandMode === "patrol" ? "🚶 PATROL MODE - Set Waypoints" : "⏹️ STOP COMMAND")
+                color: !bottomRoot.hasMovableUnits ? "#5a6c7d" : (bottomRoot.currentCommandMode === "normal" ? "#7f8c8d" : (bottomRoot.currentCommandMode === "attack" ? "#ff6b6b" : "#3498db"))
+                font.pointSize: bottomRoot.currentCommandMode === "normal" ? 10 : 11
+                font.bold: bottomRoot.currentCommandMode !== "normal" && bottomRoot.hasMovableUnits
+            }
+        }
 
-                    function getButtonColor(btn, baseColor) { if (btn.pressed) return Qt.darker(baseColor, 1.3); if (btn.checked) return baseColor; if (btn.hovered) return Qt.lighter(baseColor, 1.2); return "#2c3e50" }
+        GridLayout {
+            width: parent.width
+            columns: 3
+            rowSpacing: 6
+            columnSpacing: 6
 
-                    Button { Layout.fillWidth: true; Layout.preferredHeight: 38; text: "Attack"; focusPolicy: Qt.NoFocus; enabled: (typeof game !== 'undefined' && game.hasUnitsSelected) ? game.hasUnitsSelected : false; checkable: true; checked: bottomRoot.currentCommandMode === "attack"; background: Rectangle { color: parent.enabled ? (parent.checked ? "#e74c3c" : (parent.hovered ? "#c0392b" : "#34495e")) : "#1a252f"; radius: 6; border.color: parent.checked ? "#c0392b" : "#1a252f"; border.width: 2 } contentItem: Text { text: "⚔️\n" + parent.text; font.pointSize: 8; font.bold: true; color: parent.enabled ? "#ecf0f1" : "#7f8c8d"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } onClicked: { bottomRoot.currentCommandMode = checked ? "attack" : "normal"; bottomRoot.commandModeChanged(bottomRoot.currentCommandMode) } ToolTip.visible: hovered; ToolTip.text: game.hasUnitsSelected ? "Attack enemy units or buildings.\nUnits will chase targets." : "Select units first"; ToolTip.delay: 500 }
+            function getButtonColor(btn, baseColor) { if (btn.pressed) return Qt.darker(baseColor, 1.3); if (btn.checked) return baseColor; if (btn.hovered) return Qt.lighter(baseColor, 1.2); return "#2c3e50" }
 
-                    Button { Layout.fillWidth: true; Layout.preferredHeight: 38; text: "Guard"; focusPolicy: Qt.NoFocus; enabled: (typeof game !== 'undefined' && game.hasUnitsSelected) ? game.hasUnitsSelected : false; checkable: true; checked: bottomRoot.currentCommandMode === "guard"; background: Rectangle { color: parent.enabled ? (parent.checked ? "#3498db" : (parent.hovered ? "#2980b9" : "#34495e")) : "#1a252f"; radius: 6; border.color: parent.checked ? "#2980b9" : "#1a252f"; border.width: 2 } contentItem: Text { text: "🛡️\n" + parent.text; font.pointSize: 8; font.bold: true; color: parent.enabled ? "#ecf0f1" : "#7f8c8d"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } onClicked: { bottomRoot.currentCommandMode = checked ? "guard" : "normal"; bottomRoot.commandModeChanged(bottomRoot.currentCommandMode) } ToolTip.visible: hovered; ToolTip.text: game.hasUnitsSelected ? "Guard a position.\nUnits will defend from all sides." : "Select units first"; ToolTip.delay: 500 }
+            
+            Button { Layout.fillWidth: true; Layout.preferredHeight: 38; text: "Attack"; focusPolicy: Qt.NoFocus; enabled: bottomRoot.hasMovableUnits; checkable: true; checked: bottomRoot.currentCommandMode === "attack" && bottomRoot.hasMovableUnits; background: Rectangle { color: parent.enabled ? (parent.checked ? "#e74c3c" : (parent.hovered ? "#c0392b" : "#34495e")) : "#1a252f"; radius: 6; border.color: parent.checked ? "#c0392b" : "#1a252f"; border.width: 2 } contentItem: Text { text: "⚔️\n" + parent.text; font.pointSize: 8; font.bold: true; color: parent.enabled ? "#ecf0f1" : "#7f8c8d"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } onClicked: { bottomRoot.commandModeChanged(checked ? "attack" : "normal") } ToolTip.visible: hovered; ToolTip.text: bottomRoot.hasMovableUnits ? "Attack enemy units or buildings.\nUnits will chase targets." : "Select troops first"; ToolTip.delay: 500 }
 
-                    Button { Layout.fillWidth: true; Layout.preferredHeight: 38; text: "Patrol"; focusPolicy: Qt.NoFocus; enabled: (typeof game !== 'undefined' && game.hasUnitsSelected) ? game.hasUnitsSelected : false; checkable: true; checked: bottomRoot.currentCommandMode === "patrol"; background: Rectangle { color: parent.enabled ? (parent.checked ? "#27ae60" : (parent.hovered ? "#229954" : "#34495e")) : "#1a252f"; radius: 6; border.color: parent.checked ? "#229954" : "#1a252f"; border.width: 2 } contentItem: Text { text: "🚶\n" + parent.text; font.pointSize: 8; font.bold: true; color: parent.enabled ? "#ecf0f1" : "#7f8c8d"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } onClicked: { bottomRoot.currentCommandMode = checked ? "patrol" : "normal"; bottomRoot.commandModeChanged(bottomRoot.currentCommandMode) } ToolTip.visible: hovered; ToolTip.text: game.hasUnitsSelected ? "Patrol between waypoints.\nClick start and end points." : "Select units first"; ToolTip.delay: 500 }
+            Button { Layout.fillWidth: true; Layout.preferredHeight: 38; text: "Guard"; focusPolicy: Qt.NoFocus; enabled: bottomRoot.hasMovableUnits; checkable: true; checked: bottomRoot.currentCommandMode === "guard" && bottomRoot.hasMovableUnits; background: Rectangle { color: parent.enabled ? (parent.checked ? "#3498db" : (parent.hovered ? "#2980b9" : "#34495e")) : "#1a252f"; radius: 6; border.color: parent.checked ? "#2980b9" : "#1a252f"; border.width: 2 } contentItem: Text { text: "🛡️\n" + parent.text; font.pointSize: 8; font.bold: true; color: parent.enabled ? "#ecf0f1" : "#7f8c8d"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } onClicked: { bottomRoot.commandModeChanged(checked ? "guard" : "normal") } ToolTip.visible: hovered; ToolTip.text: bottomRoot.hasMovableUnits ? "Guard a position.\nUnits will defend from all sides." : "Select troops first"; ToolTip.delay: 500 }
 
-                    Button { Layout.fillWidth: true; Layout.preferredHeight: 38; text: "Stop"; focusPolicy: Qt.NoFocus; enabled: (typeof game !== 'undefined' && game.hasUnitsSelected) ? game.hasUnitsSelected : false; background: Rectangle { color: parent.enabled ? (parent.pressed ? "#d35400" : (parent.hovered ? "#e67e22" : "#34495e")) : "#1a252f"; radius: 6; border.color: parent.enabled ? "#d35400" : "#1a252f"; border.width: 2 } contentItem: Text { text: "⏹️\n" + parent.text; font.pointSize: 8; font.bold: true; color: parent.enabled ? "#ecf0f1" : "#7f8c8d"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } onClicked: { if (typeof game !== 'undefined' && game.onStopCommand) { game.onStopCommand() } bottomRoot.currentCommandMode = "normal" } ToolTip.visible: hovered; ToolTip.text: game.hasUnitsSelected ? "Stop all actions immediately" : "Select units first"; ToolTip.delay: 500 }
+            Button { Layout.fillWidth: true; Layout.preferredHeight: 38; text: "Patrol"; focusPolicy: Qt.NoFocus; enabled: bottomRoot.hasMovableUnits; checkable: true; checked: bottomRoot.currentCommandMode === "patrol" && bottomRoot.hasMovableUnits; background: Rectangle { color: parent.enabled ? (parent.checked ? "#27ae60" : (parent.hovered ? "#229954" : "#34495e")) : "#1a252f"; radius: 6; border.color: parent.checked ? "#229954" : "#1a252f"; border.width: 2 } contentItem: Text { text: "🚶\n" + parent.text; font.pointSize: 8; font.bold: true; color: parent.enabled ? "#ecf0f1" : "#7f8c8d"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } onClicked: { bottomRoot.commandModeChanged(checked ? "patrol" : "normal") } ToolTip.visible: hovered; ToolTip.text: bottomRoot.hasMovableUnits ? "Patrol between waypoints.\nClick start and end points." : "Select troops first"; ToolTip.delay: 500 }
 
-                    Button { Layout.fillWidth: true; Layout.preferredHeight: 38; text: "Hold"; focusPolicy: Qt.NoFocus; enabled: (typeof game !== 'undefined' && game.hasUnitsSelected) ? game.hasUnitsSelected : false; background: Rectangle { color: parent.enabled ? (parent.pressed ? "#8e44ad" : (parent.hovered ? "#9b59b6" : "#34495e")) : "#1a252f"; radius: 6; border.color: parent.enabled ? "#8e44ad" : "#1a252f"; border.width: 2 } contentItem: Text { text: "📍\n" + parent.text; font.pointSize: 8; font.bold: true; color: parent.enabled ? "#ecf0f1" : "#7f8c8d"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } onClicked: { bottomRoot.currentCommandMode = "hold"; bottomRoot.commandModeChanged(bottomRoot.currentCommandMode); Qt.callLater(function() { bottomRoot.currentCommandMode = "normal" }) } ToolTip.visible: hovered; ToolTip.text: game.hasUnitsSelected ? "Hold position and defend" : "Select units first"; ToolTip.delay: 500 }
+            Button { Layout.fillWidth: true; Layout.preferredHeight: 38; text: "Stop"; focusPolicy: Qt.NoFocus; enabled: bottomRoot.hasMovableUnits; background: Rectangle { color: parent.enabled ? (parent.pressed ? "#d35400" : (parent.hovered ? "#e67e22" : "#34495e")) : "#1a252f"; radius: 6; border.color: parent.enabled ? "#d35400" : "#1a252f"; border.width: 2 } contentItem: Text { text: "⏹️\n" + parent.text; font.pointSize: 8; font.bold: true; color: parent.enabled ? "#ecf0f1" : "#7f8c8d"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } onClicked: { if (typeof game !== 'undefined' && game.onStopCommand) { game.onStopCommand() } bottomRoot.commandModeChanged("normal") } ToolTip.visible: hovered; ToolTip.text: bottomRoot.hasMovableUnits ? "Stop all actions immediately" : "Select troops first"; ToolTip.delay: 500 }
 
-                }
-            }
+            Button { Layout.fillWidth: true; Layout.preferredHeight: 38; text: "Hold"; focusPolicy: Qt.NoFocus; enabled: bottomRoot.hasMovableUnits; background: Rectangle { color: parent.enabled ? (parent.pressed ? "#8e44ad" : (parent.hovered ? "#9b59b6" : "#34495e")) : "#1a252f"; radius: 6; border.color: parent.enabled ? "#8e44ad" : "#1a252f"; border.width: 2 } contentItem: Text { text: "📍\n" + parent.text; font.pointSize: 8; font.bold: true; color: parent.enabled ? "#ecf0f1" : "#7f8c8d"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } onClicked: { bottomRoot.commandModeChanged("hold"); Qt.callLater(function() { bottomRoot.commandModeChanged("normal") }) } ToolTip.visible: hovered; ToolTip.text: bottomRoot.hasMovableUnits ? "Hold position and defend" : "Select troops first"; ToolTip.delay: 500 }
+        }
+    }
 
-            Item { Layout.fillWidth: true }
+    
+    Rectangle {
+        Layout.fillWidth: true
+        Layout.preferredWidth: Math.max(240, bottomPanel.width * 0.30)
+        Layout.fillHeight: true
+        Layout.alignment: Qt.AlignTop
+        color: "#34495e"
+        border.color: "#1a252f"
+        border.width: 1
+
+        Column {
+            anchors.fill: parent
+            anchors.margins: 8
+            spacing: 6
 
-            
-            Rectangle {
-                Layout.preferredWidth: 220
-                Layout.fillHeight: true
-                color: "#34495e"
-                border.color: "#1a252f"
-                border.width: 1
+            Text { id: prodHeader; text: "Production"; color: "white"; font.pointSize: 11; font.bold: true }
+
+            ScrollView {
+                id: prodScroll
+                anchors.left: parent.left
+                anchors.right: parent.right
+                anchors.top: prodHeader.bottom
+                anchors.bottom: parent.bottom
+                clip: true
+                ScrollBar.vertical.policy: ScrollBar.AlwaysOn
 
                 Column {
-                    anchors.fill: parent
-                    anchors.margins: 8
+                    width: prodScroll.width
                     spacing: 6
 
-                    Text { id: prodHeader; text: "Production"; color: "white"; font.pointSize: 11; font.bold: true }
-
-                    ScrollView {
-                        id: prodScroll
-                        anchors.left: parent.left
-                        anchors.right: parent.right
-                        anchors.top: prodHeader.bottom
-                        anchors.bottom: parent.bottom
-                        clip: true
-                        ScrollBar.vertical.policy: ScrollBar.AlwaysOn
-
-                        Column {
-                            width: prodScroll.width
+                    Repeater {
+                        model: (bottomRoot.selectionTick, (typeof game !== 'undefined' && game.hasSelectedType && game.hasSelectedType("barracks"))) ? 1 : 0
+                        delegate: Column {
                             spacing: 6
+                            property var prod: (bottomRoot.selectionTick, (typeof game !== 'undefined' && game.getSelectedProductionState) ? game.getSelectedProductionState() : ({}))
+
+                            Button {
+                                id: recruitBtn
+                                text: "Recruit Archer"
+                                focusPolicy: Qt.NoFocus
+                                enabled: (function(){
+                                    if (typeof prod === 'undefined' || !prod) return false
+                                    if (!prod.hasBarracks) return false
+                                    if (prod.inProgress) return false
+                                    if (prod.producedCount >= prod.maxUnits) return false
+                                    return true
+                                })()
+                                onClicked: bottomRoot.recruit("archer")
+                            }
 
-                            
-                            Repeater {
-                                
-                                model: (bottomRoot.selectionTick, (typeof game !== 'undefined' && game.hasSelectedType && game.hasSelectedType("barracks"))) ? 1 : 0
-                                delegate: Column {
-                                    spacing: 6
-                                    property var prod: (bottomRoot.selectionTick, (typeof game !== 'undefined' && game.getSelectedProductionState) ? game.getSelectedProductionState() : ({}))
-
-                                    
-                                    Button {
-                                        id: recruitBtn
-                                        text: "Recruit Archer"
-                                        focusPolicy: Qt.NoFocus
-                                        enabled: (function(){
-                                            if (typeof prod === 'undefined' || !prod) return false
-                                            if (!prod.hasBarracks) return false
-                                            if (prod.inProgress) return false
-                                            if (prod.producedCount >= prod.maxUnits) return false
-                                            return true
-                                        })()
-                                        onClicked: bottomRoot.recruit("archer")
-                                        onPressed: bottomRoot.selectionTick += 1
-                                    }
-
-                                    
-                                    Rectangle {
-                                        width: 180
-                                        height: 8
-                                        radius: 4
-                                        color: "#1a252f"
-                                        border.color: "#2c3e50"
-                                        border.width: 1
-                                        visible: prod.inProgress
-
-                                        Rectangle {
-                                            anchors.left: parent.left
-                                            anchors.verticalCenter: parent.verticalCenter
-                                            height: parent.height
-                                            width: parent.width * (prod.buildTime > 0 ? (1.0 - Math.max(0, prod.timeRemaining) / prod.buildTime) : 0)
-                                            color: "#27ae60"
-                                            radius: 4
-                                        }
-                                    }
-
-                                    
-                                    Row {
-                                        spacing: 8
-                                        Text { text: prod.inProgress ? ("Time left: " + Math.max(0, prod.timeRemaining).toFixed(1) + "s") : ("Build time: " + (prod.buildTime || 0).toFixed(0) + "s"); color: "#bdc3c7"; font.pointSize: 9 }
-                                        Text { text: (prod.producedCount || 0) + "/" + (prod.maxUnits || 0); color: "#bdc3c7"; font.pointSize: 9 }
-                                    }
+                            Rectangle {
+                                width: 180
+                                height: 8
+                                radius: 4
+                                color: "#1a252f"
+                                border.color: "#2c3e50"
+                                border.width: 1
+                                visible: prod.inProgress
 
-                                    
-                                    Text { text: (prod.producedCount >= prod.maxUnits) ? "Cap reached" : ""; color: "#e67e22"; font.pointSize: 9 }
-
-                                    
-                                    Row {
-                                        spacing: 6
-                                        Button {
-                                            text: (typeof gameView !== 'undefined' && gameView.setRallyMode) ? "Click map to set rally (right-click to cancel)" : "Set Rally"
-                                            focusPolicy: Qt.NoFocus
-                                            enabled: !!prod.hasBarracks
-                                            onClicked: if (typeof gameView !== 'undefined') gameView.setRallyMode = !gameView.setRallyMode
-                                        }
-                                        Text { text: (typeof gameView !== 'undefined' && gameView.setRallyMode) ? "Click on the map" : ""; color: "#bdc3c7"; font.pointSize: 9 }
-                                    }
+                                Rectangle {
+                                    anchors.left: parent.left
+                                    anchors.verticalCenter: parent.verticalCenter
+                                    height: parent.height
+                                    width: parent.width * (prod.buildTime > 0 ? (1.0 - Math.max(0, prod.timeRemaining) / prod.buildTime) : 0)
+                                    color: "#27ae60"
+                                    radius: 4
                                 }
                             }
 
-                            
-                            Item {
-                                visible: (bottomRoot.selectionTick, (typeof game === 'undefined' || !game.hasSelectedType || !game.hasSelectedType("barracks")))
-                                Layout.fillWidth: true
-                                Text { text: "No production"; color: "#7f8c8d"; anchors.horizontalCenter: parent.horizontalCenter; font.pointSize: 10 }
+                            Row {
+                                spacing: 8
+                                Text { text: prod.inProgress ? ("Time left: " + Math.max(0, prod.timeRemaining).toFixed(1) + "s") : ("Build time: " + (prod.buildTime || 0).toFixed(0) + "s"); color: "#bdc3c7"; font.pointSize: 9 }
+                                Text { text: (prod.producedCount || 0) + "/" + (prod.maxUnits || 0); color: "#bdc3c7"; font.pointSize: 9 }
+                            }
+
+                            Text { text: (prod.producedCount >= prod.maxUnits) ? "Cap reached" : ""; color: "#e67e22"; font.pointSize: 9 }
+
+                            Row {
+                                spacing: 6
+                                Button {
+                                    text: (typeof gameView !== 'undefined' && gameView.setRallyMode) ? "Click map to set rally (right-click to cancel)" : "Set Rally"
+                                    focusPolicy: Qt.NoFocus
+                                    enabled: !!prod.hasBarracks
+                                    onClicked: if (typeof gameView !== 'undefined') gameView.setRallyMode = !gameView.setRallyMode
+                                }
+                                Text { text: (typeof gameView !== 'undefined' && gameView.setRallyMode) ? "Click on the map" : ""; color: "#bdc3c7"; font.pointSize: 9 }
                             }
                         }
                     }
+
+                    Item {
+                        visible: (bottomRoot.selectionTick, (typeof game === 'undefined' || !game.hasSelectedType || !game.hasSelectedType("barracks")))
+                        width: parent.width
+                        height: 30
+                        Text { text: "No production"; color: "#7f8c8d"; anchors.centerIn: parent; font.pointSize: 10 }
+                    }
                 }
             }
         }
     }
-}
+}