Browse Source

🛡️ Add Spearman Unit Type with Dedicated Renderer and Shaders

djeada 1 month ago
parent
commit
f0ff5dd9ce

+ 5 - 12
app/controllers/command_controller.cpp

@@ -78,21 +78,18 @@ CommandResult CommandController::onStopCommand() {
     if (!entity)
       continue;
 
-    // STOP clears ALL modes and actions
     resetMovement(entity);
     entity->removeComponent<Engine::Core::AttackTargetComponent>();
 
-    // Clear patrol mode
     if (auto *patrol = entity->getComponent<Engine::Core::PatrolComponent>()) {
       patrol->patrolling = false;
       patrol->waypoints.clear();
     }
 
-    // Clear hold mode (archers stand up immediately)
     auto *holdMode = entity->getComponent<Engine::Core::HoldModeComponent>();
     if (holdMode && holdMode->active) {
       holdMode->active = false;
-      holdMode->exitCooldown = holdMode->standUpDuration;  // Start stand-up transition
+      holdMode->exitCooldown = holdMode->standUpDuration;
       emit holdModeChanged(false);
     }
   }
@@ -117,32 +114,28 @@ CommandResult CommandController::onHoldCommand() {
     if (!entity)
       continue;
 
-    // HOLD MODE ONLY FOR ARCHERS (ranged units)
     auto *unit = entity->getComponent<Engine::Core::UnitComponent>();
-    if (!unit || unit->unitType != "archer")
+    // Hold mode is available for archers and spearmen (defensive formations)
+    if (!unit || (unit->unitType != "archer" && unit->unitType != "spearman"))
       continue;
 
     auto *holdMode = entity->getComponent<Engine::Core::HoldModeComponent>();
-    
-    // TOGGLE: If already in hold mode, exit it
+
     if (holdMode && holdMode->active) {
       holdMode->active = false;
       holdMode->exitCooldown = holdMode->standUpDuration;
       emit holdModeChanged(false);
-      continue;  // Don't clear other actions when toggling off
+      continue;
     }
 
-    // ENTER HOLD MODE: Clear all other modes and actions
     resetMovement(entity);
     entity->removeComponent<Engine::Core::AttackTargetComponent>();
 
-    // Clear patrol mode
     if (auto *patrol = entity->getComponent<Engine::Core::PatrolComponent>()) {
       patrol->patrolling = false;
       patrol->waypoints.clear();
     }
 
-    // Activate hold mode
     if (!holdMode) {
       holdMode = entity->addComponent<Engine::Core::HoldModeComponent>();
     }

+ 6 - 0
app/core/game_engine.cpp

@@ -317,6 +317,12 @@ void GameEngine::onHoldCommand() {
   }
 }
 
+bool GameEngine::anySelectedInHoldMode() const {
+  if (!m_commandController)
+    return false;
+  return m_commandController->anySelectedInHoldMode();
+}
+
 void GameEngine::onPatrolClick(qreal sx, qreal sy) {
   if (!m_commandController || !m_camera)
     return;

+ 1 - 0
app/core/game_engine.h

@@ -111,6 +111,7 @@ public:
   Q_INVOKABLE void onAttackClick(qreal sx, qreal sy);
   Q_INVOKABLE void onStopCommand();
   Q_INVOKABLE void onHoldCommand();
+  Q_INVOKABLE bool anySelectedInHoldMode() const;
   Q_INVOKABLE void onPatrolClick(qreal sx, qreal sy);
 
   Q_INVOKABLE void cameraMove(float dx, float dz);

+ 256 - 46
assets/shaders/spearman.frag

@@ -1,5 +1,6 @@
 #version 330 core
 
+// === Inputs preserved (do not change) ===
 in vec3 v_normal;
 in vec2 v_texCoord;
 in vec3 v_worldPos;
@@ -11,6 +12,10 @@ uniform float u_alpha;
 
 out vec4 FragColor;
 
+// === Utility ===
+float saturate(float x) { return clamp(x, 0.0, 1.0); }
+vec3  saturate(vec3  v) { return clamp(v, 0.0, 1.0); }
+
 float hash(vec2 p) {
   vec3 p3 = fract(vec3(p.xyx) * 0.1031);
   p3 += dot(p3, p3.yzx + 33.33);
@@ -34,80 +39,285 @@ float leatherGrain(vec2 p) {
   return grain + pores;
 }
 
+// Fixed bug: use 2D input (was referencing p.z).
 float fabricWeave(vec2 p) {
-  float weaveX = sin(p.x * 60.0);
-  float weaveZ = sin(p.z * 60.0);
-  return weaveX * weaveZ * 0.05;
+  float weaveU = sin(p.x * 60.0);
+  float weaveV = sin(p.y * 60.0);
+  return weaveU * weaveV * 0.05;
+}
+
+// Hemispheric ambient (simple IBL feel without extra uniforms)
+vec3 hemiAmbient(vec3 n) {
+  float up = saturate(n.y * 0.5 + 0.5);
+  vec3 sky = vec3(0.60, 0.70, 0.80) * 0.35;
+  vec3 ground = vec3(0.20, 0.18, 0.16) * 0.25;
+  return mix(ground, sky, up);
+}
+
+// Schlick Fresnel
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - saturate(cosTheta), 5.0);
+}
+
+// GGX / Trowbridge-Reitz
+float distributionGGX(float NdotH, float a) {
+  float a2 = a * a;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(3.14159265 * d * d, 1e-6);
+}
+
+// Smith's Schlick-G for GGX
+float geometrySchlickGGX(float NdotX, float k) {
+  return NdotX / max(NdotX * (1.0 - k) + k, 1e-6);
+}
+float geometrySmith(float NdotV, float NdotL, float roughness) {
+  float r = roughness + 1.0;
+  float k = (r * r) / 8.0; // Schlick approximation
+  float ggx1 = geometrySchlickGGX(NdotV, k);
+  float ggx2 = geometrySchlickGGX(NdotL, k);
+  return ggx1 * ggx2;
+}
+
+// Screen-space curvature (edge detector) from normal derivatives
+float edgeWearMask(vec3 n) {
+  vec3 nx = dFdx(n);
+  vec3 ny = dFdy(n);
+  float curvature = length(nx) + length(ny);
+  return saturate(smoothstep(0.10, 0.70, curvature));
+}
+
+// Build an approximate TBN from derivatives (no new inputs needed)
+void buildTBN(out vec3 T, out vec3 B, out vec3 N, vec3 n, vec3 pos, vec2 uv) {
+  vec3 dp1 = dFdx(pos);
+  vec3 dp2 = dFdy(pos);
+  vec2 duv1 = dFdx(uv);
+  vec2 duv2 = dFdy(uv);
+
+  float det = duv1.x * duv2.y - duv1.y * duv2.x;
+  vec3 t = (dp1 * duv2.y - dp2 * duv1.y) * (det == 0.0 ? 1.0 : sign(det));
+  T = normalize(t - n * dot(n, t));
+  B = normalize(cross(n, T));
+  N = normalize(n);
+}
+
+// Cheap bump from a procedural height map in UV space
+vec3 perturbNormalFromHeight(vec3 n, vec3 pos, vec2 uv, float height, float scale, float strength) {
+  vec3 T, B, N;
+  buildTBN(T, B, N, n, pos, uv);
+
+  // Finite-difference heights in UV for gradient
+  float h0 = height;
+  float hx = noise((uv + vec2(0.002, 0.0)) * scale) - h0;
+  float hy = noise((uv + vec2(0.0, 0.002)) * scale) - h0;
+
+  vec3 bump = normalize(N + (T * hx + B * hy) * strength);
+  return bump;
 }
 
 void main() {
+  // Base color
   vec3 color = u_color;
   if (u_useTexture) {
     color *= texture(u_texture, v_texCoord).rgb;
   }
 
-  vec3 normal = normalize(v_normal);
-  vec2 uv = v_worldPos.xz * 4.5;
+  // Inputs & coordinate prep
+  vec3 N = normalize(v_normal);
+  vec2 uvW = v_worldPos.xz * 4.5;
+  vec2 uv = v_texCoord * 4.5;
+
   float avgColor = (color.r + color.g + color.b) / 3.0;
+  float colorHue = max(max(color.r, color.g), color.b) - min(min(color.r, color.g), color.b);
 
-  float colorHue =
-      max(max(color.r, color.g), color.b) - min(min(color.r, color.g), color.b);
-  bool isMetal =
-      (avgColor > 0.45 && avgColor <= 0.65 && colorHue < 0.15);
+  // Material classification preserved
+  bool isMetal   = (avgColor > 0.45 && avgColor <= 0.65 && colorHue < 0.15);
   bool isLeather = (avgColor > 0.30 && avgColor <= 0.50 && colorHue < 0.20);
-  bool isFabric = (avgColor > 0.25 && !isMetal && !isLeather);
+  bool isFabric  = (avgColor > 0.25 && !isMetal && !isLeather);
+
+  // Lighting basis (kept compatible with prior shader)
+  vec3 L = normalize(vec3(1.0, 1.15, 1.0));
+  // Approximate view vector from world origin; nudged to avoid degenerate normalization
+  vec3 V = normalize(-v_worldPos + N * 0.001);
+  vec3 H = normalize(L + V);
+
+  float NdotL = saturate(dot(N, L));
+  float NdotV = saturate(dot(N, V));
+  float NdotH = saturate(dot(N, H));
+  float VdotH = saturate(dot(V, H));
+
+  // Ambient
+  vec3 ambient = hemiAmbient(N);
+
+  // Shared wrap diffuse (preserved behavior, slight tweak via saturate)
+  float wrapAmount = isMetal ? 0.12 : (isLeather ? 0.25 : 0.35);
+  float diffWrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.20);
+  if (isMetal) diffWrap = pow(diffWrap, 0.88);
+
+  // Edge & cavity masks (for wear/rust/shine)
+  float edgeMask   = edgeWearMask(N);           // bright edges
+  float cavityMask = 1.0 - edgeMask;           // crevices
+  // Gravity bias: downward-facing areas collect more dirt/rust
+  float downBias   = saturate((-N.y) * 0.6 + 0.4);
+  cavityMask *= downBias;
+
+  // === Material models ===
+  vec3  F0 = vec3(0.04); // default dielectric reflectance
+  float roughness = 0.6; // default roughness
+  float cavityAO = 1.0;  // occlusion multiplier
+  vec3  albedo = color;  // base diffuse/albedo
+  vec3  specular = vec3(0.0);
 
   if (isMetal) {
-    float metalBrushed = abs(sin(v_worldPos.y * 80.0)) * 0.025;
-    float rust = noise(uv * 8.0) * 0.10;
-    float dents = noise(uv * 6.0) * 0.035;
+    // Use texture UVs for stability (as in original)
+    vec2 metalUV = v_texCoord * 4.5;
 
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
-    float metalSheen = pow(viewAngle, 9.0) * 0.30;
-    float metalFresnel = pow(1.0 - viewAngle, 2.0) * 0.22;
+    // Brushed/anisotropic micro-lines & microdents
+    float brushed = abs(sin(v_texCoord.y * 85.0)) * 0.022;
+    float dents   = noise(metalUV * 6.0) * 0.035;
+    float rustTex = noise(metalUV * 8.0) * 0.10;
 
-    color += vec3(metalSheen + metalFresnel);
-    color += vec3(metalBrushed);
-    color -= vec3(rust * 0.35 + dents * 0.25);
-  }
-  else if (isLeather) {
-    float leather = leatherGrain(uv);
-    float wear = noise(uv * 4.0) * 0.12 - 0.06;
+    // Small directional scratches
+    float scratchLines = smoothstep(0.97, 1.0, abs(sin(metalUV.x * 160.0 + noise(metalUV * 3.0) * 2.0)));
+    scratchLines *= 0.08;
+
+    // Procedural height for bumping (kept subtle to avoid shimmer)
+    float height = noise(metalUV * 12.0) * 0.5 + brushed * 2.0 + scratchLines;
+    vec3  Np = perturbNormalFromHeight(N, v_worldPos, v_texCoord, height, 12.0, 0.55);
+    N = mix(N, Np, 0.65); // blend to keep stable
+
+    // Physically-based specular with GGX
+    roughness = clamp(0.18 + brushed * 0.35 + dents * 0.25 + rustTex * 0.30 - edgeMask * 0.12, 0.05, 0.9);
+    float a = max(0.001, roughness * roughness);
 
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    // Metals take F0 from their base color
+    F0 = saturate(color);
+
+    // Rust/dirt reduce albedo and boost roughness in cavities
+    float rustMask = smoothstep(0.55, 0.85, noise(metalUV * 5.5)) * cavityMask;
+    vec3 rustTint = vec3(0.35, 0.18, 0.08); // warm iron-oxide tint
+    albedo = mix(albedo, albedo * 0.55 + rustTint * 0.35, rustMask);
+    roughness = clamp(mix(roughness, 0.85, rustMask), 0.05, 0.95);
+
+    // Edge wear: brighten edges with lower roughness (polished)
+    albedo = mix(albedo, albedo * 1.12 + vec3(0.05), edgeMask * 0.7);
+    roughness = clamp(mix(roughness, 0.10, edgeMask * 0.5), 0.05, 0.95);
+
+    // Recompute lighting terms with updated normal
+    H = normalize(L + V);
+    NdotL = saturate(dot(N, L));
+    NdotV = saturate(dot(N, V));
+    NdotH = saturate(dot(N, H));
+    VdotH = saturate(dot(V, H));
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3  F = fresnelSchlick(VdotH, F0);
+
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    // Clearcoat sparkle (very subtle tight lobe)
+    float aCoat = 0.04; // ~roughness 0.2
+    float Dcoat = distributionGGX(NdotH, aCoat);
+    float Gcoat = geometrySmith(NdotV, NdotL, sqrt(aCoat));
+    vec3  Fcoat = fresnelSchlick(VdotH, vec3(0.04));
+    specular += 0.06 * (Dcoat * Gcoat * Fcoat) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    // Metals have almost no diffuse term
+    float kD = 0.0;
+    vec3 diffuse = vec3(kD);
+
+    // AO from cavities
+    cavityAO = 1.0 - rustMask * 0.6;
+
+    // Final combine (ambient + wrapped diffuse + specular)
+    vec3 lit = ambient * albedo * cavityAO
+             + diffWrap * albedo * diffuse
+             + specular * NdotL;
+
+    // Small addition of brushed sheen from the original
+    lit += vec3(brushed) * 0.8;
+
+    color = lit;
+
+  } else if (isLeather) {
+    // Leather microstructure & wear
+    float leather = leatherGrain(uvW);
+    float wear    = noise(uvW * 4.0) * 0.12 - 0.06;
+
+    float viewAngle = abs(dot(N, normalize(vec3(0.0, 1.0, 0.5))));
     float leatherSheen = pow(1.0 - viewAngle, 5.0) * 0.12;
 
-    color *= 1.0 + leather - 0.08 + wear;
-    color += vec3(leatherSheen);
-  }
-  else if (isFabric) {
+    albedo *= 1.0 + leather - 0.08 + wear;
+    albedo += vec3(leatherSheen);
+
+    // Leather: dielectric
+    roughness = clamp(0.55 + leather * 0.25, 0.2, 0.95);
+    float a = roughness * roughness;
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3  F = fresnelSchlick(VdotH, F0);
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    float kD = 1.0 - max(max(F.r, F.g), F.b);
+    vec3 diffuse = kD * albedo;
+
+    cavityAO = 1.0 - (noise(uvW * 3.0) * 0.15) * cavityMask;
+
+    color = ambient * albedo * cavityAO
+          + diffWrap * diffuse
+          + specular * NdotL;
+
+  } else if (isFabric) {
     float weave = fabricWeave(v_worldPos.xz);
-    float fabricFuzz = noise(uv * 18.0) * 0.08;
-    float folds = noise(uv * 5.0) * 0.10 - 0.05;
+    float fabricFuzz = noise(uvW * 18.0) * 0.08;
+    float folds = noise(uvW * 5.0) * 0.10 - 0.05;
 
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
+    float viewAngle = abs(dot(N, normalize(vec3(0.0, 1.0, 0.5))));
     float fabricSheen = pow(1.0 - viewAngle, 7.0) * 0.10;
 
-    color *= 1.0 + fabricFuzz - 0.04 + folds;
-    color += vec3(weave + fabricSheen);
-  }
-  else {
-    float detail = noise(uv * 8.0) * 0.14;
-    color *= 1.0 + detail - 0.07;
-  }
+    albedo *= 1.0 + fabricFuzz - 0.04 + folds;
+    albedo += vec3(weave + fabricSheen);
 
-  color = clamp(color, 0.0, 1.0);
+    roughness = clamp(0.65 + fabricFuzz * 0.25, 0.3, 0.98);
+    float a = roughness * roughness;
 
-  vec3 lightDir = normalize(vec3(1.0, 1.15, 1.0));
-  float nDotL = dot(normal, lightDir);
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3  F = fresnelSchlick(VdotH, F0);
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
 
-  float wrapAmount = isMetal ? 0.12 : (isLeather ? 0.25 : 0.35);
-  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.20);
+    float kD = 1.0 - max(max(F.r, F.g), F.b);
+    vec3 diffuse = kD * albedo;
 
-  if (isMetal) {
-    diff = pow(diff, 0.88);
+    cavityAO = 1.0 - (noise(uvW * 2.5) * 0.10) * cavityMask;
+
+    color = ambient * albedo * cavityAO
+          + diffWrap * diffuse
+          + specular * NdotL;
+
+  } else {
+    // Generic matte
+    float detail = noise(uvW * 8.0) * 0.14;
+    albedo *= 1.0 + detail - 0.07;
+
+    roughness = 0.7;
+    float a = roughness * roughness;
+
+    float D = distributionGGX(NdotH, a);
+    float G = geometrySmith(NdotV, NdotL, roughness);
+    vec3  F = fresnelSchlick(VdotH, F0);
+    specular = (D * G * F) / max(4.0 * NdotL * NdotV, 1e-4);
+
+    float kD = 1.0 - max(max(F.r, F.g), F.b);
+    vec3 diffuse = kD * albedo;
+
+    color = ambient * albedo
+          + diffWrap * diffuse
+          + specular * NdotL;
   }
 
-  color *= diff;
+  // Final color clamp and alpha preserved
+  color = saturate(color);
   FragColor = vec4(color, u_alpha);
 }

+ 1 - 1
game/core/component.h

@@ -207,7 +207,7 @@ public:
 
   bool active;
   float exitCooldown;
-  float standUpDuration;  // Time it takes to stand up from kneeling (seconds)
+  float standUpDuration;
 };
 
 } // namespace Engine::Core

+ 17 - 3
game/core/world.cpp

@@ -10,6 +10,7 @@ World::World() = default;
 World::~World() = default;
 
 Entity *World::createEntity() {
+  std::lock_guard<std::recursive_mutex> lock(m_entityMutex);
   EntityID id = m_nextEntityId++;
   auto entity = std::make_unique<Entity>(id);
   auto ptr = entity.get();
@@ -18,6 +19,7 @@ Entity *World::createEntity() {
 }
 
 Entity *World::createEntityWithId(EntityID id) {
+  std::lock_guard<std::recursive_mutex> lock(m_entityMutex);
   if (id == NULL_ENTITY) {
     return nullptr;
   }
@@ -33,15 +35,19 @@ Entity *World::createEntityWithId(EntityID id) {
   return ptr;
 }
 
-void World::destroyEntity(EntityID id) { m_entities.erase(id); }
+void World::destroyEntity(EntityID id) {
+  std::lock_guard<std::recursive_mutex> lock(m_entityMutex);
+  m_entities.erase(id);
+}
 
 void World::clear() {
-
+  std::lock_guard<std::recursive_mutex> lock(m_entityMutex);
   m_entities.clear();
   m_nextEntityId = 1;
 }
 
 Entity *World::getEntity(EntityID id) {
+  std::lock_guard<std::recursive_mutex> lock(m_entityMutex);
   auto it = m_entities.find(id);
   return it != m_entities.end() ? it->second.get() : nullptr;
 }
@@ -57,6 +63,7 @@ void World::update(float deltaTime) {
 }
 
 std::vector<Entity *> World::getUnitsOwnedBy(int ownerId) const {
+  std::lock_guard<std::recursive_mutex> lock(m_entityMutex);
   std::vector<Entity *> result;
   result.reserve(m_entities.size());
   for (auto &[id, entity] : m_entities) {
@@ -71,6 +78,7 @@ std::vector<Entity *> World::getUnitsOwnedBy(int ownerId) const {
 }
 
 std::vector<Entity *> World::getUnitsNotOwnedBy(int ownerId) const {
+  std::lock_guard<std::recursive_mutex> lock(m_entityMutex);
   std::vector<Entity *> result;
   result.reserve(m_entities.size());
   for (auto &[id, entity] : m_entities) {
@@ -85,6 +93,7 @@ std::vector<Entity *> World::getUnitsNotOwnedBy(int ownerId) const {
 }
 
 std::vector<Entity *> World::getAlliedUnits(int ownerId) const {
+  std::lock_guard<std::recursive_mutex> lock(m_entityMutex);
   std::vector<Entity *> result;
   result.reserve(m_entities.size());
   auto &ownerRegistry = Game::Systems::OwnerRegistry::instance();
@@ -103,6 +112,7 @@ std::vector<Entity *> World::getAlliedUnits(int ownerId) const {
 }
 
 std::vector<Entity *> World::getEnemyUnits(int ownerId) const {
+  std::lock_guard<std::recursive_mutex> lock(m_entityMutex);
   std::vector<Entity *> result;
   result.reserve(m_entities.size());
   auto &ownerRegistry = Game::Systems::OwnerRegistry::instance();
@@ -123,9 +133,13 @@ int World::countTroopsForPlayer(int ownerId) const {
   return Game::Systems::TroopCountRegistry::instance().getTroopCount(ownerId);
 }
 
-EntityID World::getNextEntityId() const { return m_nextEntityId; }
+EntityID World::getNextEntityId() const { 
+  std::lock_guard<std::recursive_mutex> lock(m_entityMutex);
+  return m_nextEntityId; 
+}
 
 void World::setNextEntityId(EntityID nextId) {
+  std::lock_guard<std::recursive_mutex> lock(m_entityMutex);
   m_nextEntityId = std::max(nextId, m_nextEntityId);
 }
 

+ 7 - 0
game/core/world.h

@@ -3,6 +3,7 @@
 #include "entity.h"
 #include "system.h"
 #include <memory>
+#include <mutex>
 #include <unordered_map>
 #include <vector>
 
@@ -34,6 +35,7 @@ public:
   }
 
   template <typename T> std::vector<Entity *> getEntitiesWith() {
+    std::lock_guard<std::recursive_mutex> lock(m_entityMutex);
     std::vector<Entity *> result;
     for (auto &[id, entity] : m_entities) {
       if (entity->hasComponent<T>()) {
@@ -57,10 +59,15 @@ public:
   EntityID getNextEntityId() const;
   void setNextEntityId(EntityID nextId);
 
+  // Thread safety for entity operations (render thread vs game thread)
+  // Uses recursive_mutex to allow nested locking within the same thread
+  std::recursive_mutex &getEntityMutex() { return m_entityMutex; }
+
 private:
   EntityID m_nextEntityId = 1;
   std::unordered_map<EntityID, std::unique_ptr<Entity>> m_entities;
   std::vector<std::unique_ptr<System>> m_systems;
+  mutable std::recursive_mutex m_entityMutex; // Allows nested locks from same thread
 };
 
 } // namespace Engine::Core

+ 31 - 33
game/systems/combat_system.cpp

@@ -26,7 +26,7 @@ void CombatSystem::processAttacks(Engine::Core::World *world, float deltaTime) {
   ArrowSystem *arrowSys = world->getSystem<ArrowSystem>();
 
   for (auto attacker : units) {
-    // Skip entities pending removal to avoid accessing destroyed objects
+
     if (attacker->hasComponent<Engine::Core::PendingRemovalComponent>())
       continue;
 
@@ -44,7 +44,8 @@ void CombatSystem::processAttacks(Engine::Core::World *world, float deltaTime) {
 
     if (attackerAtk && attackerAtk->inMeleeLock) {
       auto *lockTarget = world->getEntity(attackerAtk->meleeLockTargetId);
-      if (!lockTarget || lockTarget->hasComponent<Engine::Core::PendingRemovalComponent>()) {
+      if (!lockTarget ||
+          lockTarget->hasComponent<Engine::Core::PendingRemovalComponent>()) {
 
         attackerAtk->inMeleeLock = false;
         attackerAtk->meleeLockTargetId = 0;
@@ -87,7 +88,8 @@ void CombatSystem::processAttacks(Engine::Core::World *world, float deltaTime) {
     if (attackerAtk && attackerAtk->inMeleeLock &&
         attackerAtk->meleeLockTargetId != 0) {
       auto *lockTarget = world->getEntity(attackerAtk->meleeLockTargetId);
-      if (lockTarget && !lockTarget->hasComponent<Engine::Core::PendingRemovalComponent>()) {
+      if (lockTarget &&
+          !lockTarget->hasComponent<Engine::Core::PendingRemovalComponent>()) {
 
         auto *attackTarget =
             attacker->getComponent<Engine::Core::AttackTargetComponent>();
@@ -118,9 +120,15 @@ void CombatSystem::processAttacks(Engine::Core::World *world, float deltaTime) {
 
       auto *holdMode =
           attacker->getComponent<Engine::Core::HoldModeComponent>();
-      if (holdMode && holdMode->active && attackerUnit->unitType == "archer") {
-        range *= 1.5f;
-        damage = static_cast<int>(damage * 1.3f);
+      if (holdMode && holdMode->active) {
+        if (attackerUnit->unitType == "archer") {
+          // Archers: improved range and accuracy when kneeling
+          range *= 1.5f;
+          damage = static_cast<int>(damage * 1.3f);
+        } else if (attackerUnit->unitType == "spearman") {
+          // Spearmen: braced spear formation increases damage against charges
+          damage = static_cast<int>(damage * 1.4f);
+        }
       }
 
       attackerAtk->timeSinceLast += deltaTime;
@@ -141,7 +149,8 @@ void CombatSystem::processAttacks(Engine::Core::World *world, float deltaTime) {
     if (attackTarget && attackTarget->targetId != 0) {
 
       auto *target = world->getEntity(attackTarget->targetId);
-      if (target && !target->hasComponent<Engine::Core::PendingRemovalComponent>()) {
+      if (target &&
+          !target->hasComponent<Engine::Core::PendingRemovalComponent>()) {
         auto *targetUnit = target->getComponent<Engine::Core::UnitComponent>();
 
         auto &ownerRegistry = Game::Systems::OwnerRegistry::instance();
@@ -379,8 +388,8 @@ void CombatSystem::processAttacks(Engine::Core::World *world, float deltaTime) {
             QVector3D upVector(0.0f, 1.0f, 0.0f);
 
             float lateralOffset = spreadDist(spreadGen);
-            float verticalOffset = spreadDist(spreadGen) * 0.5f;
-            float depthOffset = spreadDist(spreadGen) * 0.3f;
+            float verticalOffset = spreadDist(spreadGen) * 1.5f;
+            float depthOffset = spreadDist(spreadGen) * 1.3f;
 
             QVector3D startOffset =
                 perpendicular * lateralOffset + upVector * verticalOffset;
@@ -537,7 +546,9 @@ void CombatSystem::dealDamage(Engine::Core::World *world,
 
         if (world) {
           auto *lockPartner = world->getEntity(targetAtk->meleeLockTargetId);
-          if (lockPartner && !lockPartner->hasComponent<Engine::Core::PendingRemovalComponent>()) {
+          if (lockPartner &&
+              !lockPartner
+                   ->hasComponent<Engine::Core::PendingRemovalComponent>()) {
             auto *partnerAtk =
                 lockPartner->getComponent<Engine::Core::AttackComponent>();
             if (partnerAtk &&
@@ -665,7 +676,6 @@ void CombatSystem::processAutoEngagement(Engine::Core::World *world,
                                          float deltaTime) {
   auto units = world->getEntitiesWith<Engine::Core::UnitComponent>();
 
-  // Update cooldowns
   for (auto it = m_engagementCooldowns.begin();
        it != m_engagementCooldowns.end();) {
     it->second -= deltaTime;
@@ -677,7 +687,7 @@ void CombatSystem::processAutoEngagement(Engine::Core::World *world,
   }
 
   for (auto unit : units) {
-    // Skip if unit is not alive or pending removal
+
     if (unit->hasComponent<Engine::Core::PendingRemovalComponent>()) {
       continue;
     }
@@ -687,52 +697,45 @@ void CombatSystem::processAutoEngagement(Engine::Core::World *world,
       continue;
     }
 
-    // Skip if unit is a building
     if (unit->hasComponent<Engine::Core::BuildingComponent>()) {
       continue;
     }
 
-    // Skip if unit doesn't have attack capability or is not melee-capable
     auto attackComp = unit->getComponent<Engine::Core::AttackComponent>();
     if (!attackComp || !attackComp->canMelee) {
       continue;
     }
 
-    // Only auto-engage for pure melee units or units that prefer melee
-    // This prevents ranged units from unnecessarily running into melee range
     if (attackComp->canRanged &&
         attackComp->preferredMode !=
             Engine::Core::AttackComponent::CombatMode::Melee) {
       continue;
     }
 
-    // Skip if unit is on engagement cooldown
     if (m_engagementCooldowns.find(unit->getId()) !=
         m_engagementCooldowns.end()) {
       continue;
     }
 
-    // Skip if unit is not idle
     if (!isUnitIdle(unit)) {
       continue;
     }
 
-    // Find nearest enemy within vision range
     float visionRange = unitComp->visionRange;
     auto *nearestEnemy = findNearestEnemy(unit, world, visionRange);
 
     if (nearestEnemy) {
-      // Issue attack command to engage the enemy
+
       auto *attackTarget =
           unit->getComponent<Engine::Core::AttackTargetComponent>();
       if (!attackTarget) {
-        attackTarget = unit->addComponent<Engine::Core::AttackTargetComponent>();
+        attackTarget =
+            unit->addComponent<Engine::Core::AttackTargetComponent>();
       }
       if (attackTarget) {
         attackTarget->targetId = nearestEnemy->getId();
         attackTarget->shouldChase = true;
 
-        // Add cooldown to prevent rapid re-engagement
         m_engagementCooldowns[unit->getId()] = ENGAGEMENT_COOLDOWN;
       }
     }
@@ -740,31 +743,28 @@ void CombatSystem::processAutoEngagement(Engine::Core::World *world,
 }
 
 bool CombatSystem::isUnitIdle(Engine::Core::Entity *unit) {
-  // Check if unit is in hold mode
+
   auto *holdMode = unit->getComponent<Engine::Core::HoldModeComponent>();
   if (holdMode && holdMode->active) {
     return false;
   }
 
-  // Check if unit already has an attack target
-  auto *attackTarget = unit->getComponent<Engine::Core::AttackTargetComponent>();
+  auto *attackTarget =
+      unit->getComponent<Engine::Core::AttackTargetComponent>();
   if (attackTarget && attackTarget->targetId != 0) {
     return false;
   }
 
-  // Check if unit is currently moving to a target
   auto *movement = unit->getComponent<Engine::Core::MovementComponent>();
   if (movement && movement->hasTarget) {
     return false;
   }
 
-  // Check if unit is in melee lock
   auto *attackComp = unit->getComponent<Engine::Core::AttackComponent>();
   if (attackComp && attackComp->inMeleeLock) {
     return false;
   }
 
-  // Check if unit is patrolling
   auto *patrol = unit->getComponent<Engine::Core::PatrolComponent>();
   if (patrol && patrol->patrolling) {
     return false;
@@ -773,8 +773,9 @@ bool CombatSystem::isUnitIdle(Engine::Core::Entity *unit) {
   return true;
 }
 
-Engine::Core::Entity *CombatSystem::findNearestEnemy(
-    Engine::Core::Entity *unit, Engine::Core::World *world, float maxRange) {
+Engine::Core::Entity *CombatSystem::findNearestEnemy(Engine::Core::Entity *unit,
+                                                     Engine::Core::World *world,
+                                                     float maxRange) {
   auto unitComp = unit->getComponent<Engine::Core::UnitComponent>();
   auto unitTransform = unit->getComponent<Engine::Core::TransformComponent>();
   if (!unitComp || !unitTransform) {
@@ -792,7 +793,6 @@ Engine::Core::Entity *CombatSystem::findNearestEnemy(
       continue;
     }
 
-    // Skip entities pending removal
     if (target->hasComponent<Engine::Core::PendingRemovalComponent>()) {
       continue;
     }
@@ -802,7 +802,6 @@ Engine::Core::Entity *CombatSystem::findNearestEnemy(
       continue;
     }
 
-    // Skip allies and same team
     if (targetUnit->ownerId == unitComp->ownerId) {
       continue;
     }
@@ -810,7 +809,6 @@ Engine::Core::Entity *CombatSystem::findNearestEnemy(
       continue;
     }
 
-    // Skip buildings for melee units (they should not auto-engage buildings)
     if (target->hasComponent<Engine::Core::BuildingComponent>()) {
       continue;
     }

+ 2 - 3
game/systems/command_service.cpp

@@ -115,7 +115,7 @@ void CommandService::moveUnits(Engine::Core::World &world,
 
     auto *holdMode = e->getComponent<Engine::Core::HoldModeComponent>();
     if (holdMode && holdMode->active) {
-      // Only start stand-up transition if unit was actually in hold mode
+
       holdMode->active = false;
       holdMode->exitCooldown = holdMode->standUpDuration;
     }
@@ -323,7 +323,6 @@ void CommandService::moveGroup(Engine::Core::World &world,
     if (!entity)
       continue;
 
-    // Handle hold mode exit for archers in group moves
     auto *holdMode = entity->getComponent<Engine::Core::HoldModeComponent>();
     if (holdMode && holdMode->active) {
       holdMode->active = false;
@@ -692,7 +691,7 @@ void CommandService::attackTarget(
 
     auto *holdMode = e->getComponent<Engine::Core::HoldModeComponent>();
     if (holdMode && holdMode->active) {
-      // Only start stand-up transition if unit was actually in hold mode
+
       holdMode->active = false;
       holdMode->exitCooldown = holdMode->standUpDuration;
     }

+ 1 - 4
game/systems/movement_system.cpp

@@ -122,13 +122,10 @@ void MovementSystem::moveUnit(Engine::Core::Entity *entity,
       inHoldMode = true;
     }
 
-    // During stand-up transition (exitCooldown > 0), block movement execution
-    // but DON'T clear movement targets - unit remembers where to go
     if (holdMode->exitCooldown > 0.0f && !inHoldMode) {
       movement->vx = 0.0f;
       movement->vz = 0.0f;
-      // Keep hasTarget, path, and pathPending intact!
-      // Unit will start moving once exitCooldown reaches 0
+
       return;
     }
   }

+ 5 - 1
game/units/troop_config.h

@@ -58,7 +58,11 @@ private:
 
     m_individualsPerUnit["knight"] = 15;
     m_maxUnitsPerRow["knight"] = 5;
-    m_selectionRingSize["knight"] = 1.4f;
+    m_selectionRingSize["knight"] = 1.1f;
+
+    m_individualsPerUnit["spearman"] = 24;
+    m_maxUnitsPerRow["spearman"] = 6;
+    m_selectionRingSize["spearman"] = 1.4f;
   }
 
   std::unordered_map<std::string, int> m_individualsPerUnit;

+ 100 - 127
render/entity/archer_renderer.cpp

@@ -51,7 +51,8 @@ struct ArcherExtras {
 class ArcherRenderer : public HumanoidRendererBase {
 public:
   QVector3D getProportionScaling() const override {
-    return QVector3D(0.92f, 1.00f, 0.95f);
+    // Slightly more refined proportions for archer
+    return QVector3D(0.94f, 1.01f, 0.96f);
   }
 
 private:
@@ -73,82 +74,47 @@ public:
 
     float bowX = 0.0f;
 
-    // Apply kneeling pose if in hold mode OR transitioning out
     if (anim.isInHoldMode || anim.isExitingHold) {
-      // t: 1.0 = fully kneeling, 0.0 = fully standing
+
       float t = anim.isInHoldMode ? 1.0f : (1.0f - anim.holdExitProgress);
 
-      // LEG-DRIVEN KNEEL: Sniper-style stance
-      // - Narrow stance (legs close together, not spread)
-      // - Left knee on ground, shin horizontal back
-      // - Right leg forms L-shape: thigh down, shin back to planted foot
-      
-      float kneelDepth = 0.45f * t;  // How much character lowers
-      
-      // PELVIS lowers via leg bending
+      float kneelDepth = 0.45f * t;
+
       float pelvisY = HP::WAIST_Y - kneelDepth;
       pose.pelvisPos.setY(pelvisY);
-      
-      // SNIPER STANCE: Narrow stance, legs close to body centerline
-      float stanceNarrow = 0.12f;  // Narrow stance
-      
-      // LEFT LEG: Knee on ground, shin ~parallel to ground, foot back
-      // Upper leg: pelvis → knee (should be ~0.35 units)
-      // Lower leg: knee → foot (should be ~0.35 units)
-      float leftKneeY = HP::GROUND_Y + 0.08f * t;  // Knee on/near ground
-      float leftKneeZ = -0.05f * t;  // Knee at body center
-      
+
+      float stanceNarrow = 0.12f;
+
+      float leftKneeY = HP::GROUND_Y + 0.08f * t;
+      float leftKneeZ = -0.05f * t;
+
       pose.kneeL = QVector3D(-stanceNarrow, leftKneeY, leftKneeZ);
-      
-      // Foot behind knee - shin horizontal/slightly angled back
-      pose.footL = QVector3D(
-        -stanceNarrow - 0.03f,  // Foot slightly outward
-        HP::GROUND_Y,
-        leftKneeZ - HP::LOWER_LEG_LEN * 0.95f * t  // Full shin length back
-      );
-      
-      // RIGHT LEG: SNIPER L-SHAPE - ROTATED 90°
-      // Thigh HORIZONTAL (parallel to ground, extending forward from pelvis)
-      // Calf VERTICAL (drops down from knee to foot on ground)
-      // 
-      // Pelvis is at (0, pelvisY, ~0)
-      // Knee should be FORWARD from pelvis at roughly same Y height
-      // Foot on ground below knee
-      
-      float rightFootZ = 0.30f * t;  // Foot forward for stability
-      pose.footR = QVector3D(
-        stanceNarrow,  // Narrow stance
-        HP::GROUND_Y + pose.footYOffset,
-        rightFootZ  // Foot forward on ground
-      );
-      
-      // Knee: forward from pelvis, at roughly pelvis height (thigh horizontal!)
-      // The thigh extends forward in +Z direction, not down in -Y
-      float rightKneeY = pelvisY - 0.10f;  // Knee slightly below pelvis (not hanging down!)
-      float rightKneeZ = rightFootZ - 0.05f;  // Knee directly above/behind foot
-      
-      pose.kneeR = QVector3D(
-        stanceNarrow,
-        rightKneeY,
-        rightKneeZ  // Knee above foot, creating vertical calf
-      );
-
-      // RIGID UPPER BODY DROP: Entire upper rig translates down (NO COMPRESSION!)
+
+      pose.footL = QVector3D(-stanceNarrow - 0.03f, HP::GROUND_Y,
+                             leftKneeZ - HP::LOWER_LEG_LEN * 0.95f * t);
+
+      float rightFootZ = 0.30f * t;
+      pose.footR =
+          QVector3D(stanceNarrow, HP::GROUND_Y + pose.footYOffset, rightFootZ);
+
+      float rightKneeY = pelvisY - 0.10f;
+      float rightKneeZ = rightFootZ - 0.05f;
+
+      pose.kneeR = QVector3D(stanceNarrow, rightKneeY, rightKneeZ);
+
       float upperBodyDrop = kneelDepth;
-      
+
       pose.shoulderL.setY(HP::SHOULDER_Y - upperBodyDrop);
       pose.shoulderR.setY(HP::SHOULDER_Y - upperBodyDrop);
       pose.neckBase.setY(HP::NECK_BASE_Y - upperBodyDrop);
       pose.headPos.setY((HP::HEAD_TOP_Y + HP::CHIN_Y) * 0.5f - upperBodyDrop);
 
-      // Slight forward lean for archer stance
       float forwardLean = 0.10f * t;
       pose.shoulderL.setZ(pose.shoulderL.z() + forwardLean);
       pose.shoulderR.setZ(pose.shoulderR.z() + forwardLean);
       pose.neckBase.setZ(pose.neckBase.z() + forwardLean * 0.8f);
       pose.headPos.setZ(pose.headPos.z() + forwardLean * 0.7f);
 
-      // Hand positions for holding bow raised (sky-facing stance)
       QVector3D holdHandL(bowX - 0.15f, pose.shoulderL.y() + 0.30f, 0.55f);
       QVector3D holdHandR(bowX + 0.12f, pose.shoulderR.y() + 0.15f, 0.10f);
       QVector3D normalHandL(bowX - 0.05f + armAsymmetry,
@@ -310,21 +276,26 @@ public:
                   const HumanoidPose &pose, ISubmitter &out) const override {
     using HP = HumanProportions;
 
-    QVector3D helmetColor = v.palette.metal * QVector3D(1.1f, 0.95f, 0.7f);
-    QVector3D helmetTop(0, pose.headPos.y() + pose.headR * 1.25f, 0);
-    QVector3D helmetBot(0, pose.headPos.y() + pose.headR * 0.10f, 0);
-    float helmetR = pose.headR * 1.08f;
+    // Enhanced metallic helmet with better detailing
+    QVector3D helmetColor = v.palette.metal * QVector3D(1.08f, 0.98f, 0.78f);
+    QVector3D helmetAccent = helmetColor * 1.12f;
+    
+    QVector3D helmetTop(0, pose.headPos.y() + pose.headR * 1.28f, 0);
+    QVector3D helmetBot(0, pose.headPos.y() + pose.headR * 0.08f, 0);
+    float helmetR = pose.headR * 1.10f;
 
+    // Main helmet body with slight taper
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, helmetBot, helmetTop, helmetR),
              helmetColor, nullptr, 1.0f);
 
-    QVector3D apexPos(0, pose.headPos.y() + pose.headR * 1.45f, 0);
+    // Smoother conical top
+    QVector3D apexPos(0, pose.headPos.y() + pose.headR * 1.48f, 0);
     out.mesh(getUnitCone(),
-             coneFromTo(ctx.model, helmetTop, apexPos, helmetR * 0.95f),
-             helmetColor * 1.05f, nullptr, 1.0f);
+             coneFromTo(ctx.model, helmetTop, apexPos, helmetR * 0.97f),
+             helmetAccent, nullptr, 1.0f);
 
-    QVector3D browPos(0, pose.headPos.y() + pose.headR * 0.35f, 0);
+    // Reinforcement rings for visual interest
     auto ring = [&](const QVector3D &center, float r, float h,
                     const QVector3D &col) {
       QVector3D a = center + QVector3D(0, h * 0.5f, 0);
@@ -332,46 +303,65 @@ public:
       out.mesh(getUnitCylinder(), cylinderBetween(ctx.model, a, b, r), col,
                nullptr, 1.0f);
     };
-    ring(browPos, helmetR * 1.06f, 0.018f, helmetColor * 1.1f);
-
-    float cheekW = pose.headR * 0.45f;
-    float cheekH = pose.headR * 0.65f;
-    QVector3D cheekTop(0, pose.headPos.y() + pose.headR * 0.25f, 0);
-    QVector3D cheekBot(0, pose.headPos.y() - pose.headR * 0.40f, 0);
-
-    QVector3D cheekLTop = cheekTop + QVector3D(-cheekW, 0, pose.headR * 0.35f);
-    QVector3D cheekLBot =
-        cheekBot + QVector3D(-cheekW * 0.8f, 0, pose.headR * 0.25f);
+    
+    // Brow reinforcement
+    QVector3D browPos(0, pose.headPos.y() + pose.headR * 0.35f, 0);
+    ring(browPos, helmetR * 1.07f, 0.020f, helmetAccent);
+    
+    // Temple bands
+    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.65f, 0), 
+         helmetR * 1.03f, 0.015f, helmetColor * 1.05f);
+    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.95f, 0), 
+         helmetR * 1.01f, 0.012f, helmetColor * 1.03f);
+
+    // Improved cheek guards - more realistic positioning
+    float cheekW = pose.headR * 0.48f;
+    QVector3D cheekTop(0, pose.headPos.y() + pose.headR * 0.22f, 0);
+    QVector3D cheekBot(0, pose.headPos.y() - pose.headR * 0.42f, 0);
+
+    // Left cheek guard
+    QVector3D cheekLTop = cheekTop + QVector3D(-cheekW, 0, pose.headR * 0.38f);
+    QVector3D cheekLBot = cheekBot + QVector3D(-cheekW * 0.82f, 0, pose.headR * 0.28f);
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, cheekLBot, cheekLTop, 0.025f),
-             helmetColor * 0.95f, nullptr, 1.0f);
+             cylinderBetween(ctx.model, cheekLBot, cheekLTop, 0.028f),
+             helmetColor * 0.96f, nullptr, 1.0f);
 
-    QVector3D cheekRTop = cheekTop + QVector3D(cheekW, 0, pose.headR * 0.35f);
-    QVector3D cheekRBot =
-        cheekBot + QVector3D(cheekW * 0.8f, 0, pose.headR * 0.25f);
+    // Right cheek guard
+    QVector3D cheekRTop = cheekTop + QVector3D(cheekW, 0, pose.headR * 0.38f);
+    QVector3D cheekRBot = cheekBot + QVector3D(cheekW * 0.82f, 0, pose.headR * 0.28f);
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, cheekRBot, cheekRTop, 0.025f),
-             helmetColor * 0.95f, nullptr, 1.0f);
-
-    QVector3D neckGuardTop(0, pose.headPos.y() + pose.headR * 0.05f,
-                           -pose.headR * 0.80f);
-    QVector3D neckGuardBot(0, pose.headPos.y() - pose.headR * 0.30f,
-                           -pose.headR * 0.85f);
+             cylinderBetween(ctx.model, cheekRBot, cheekRTop, 0.028f),
+             helmetColor * 0.96f, nullptr, 1.0f);
+
+    // Enhanced neck guard
+    QVector3D neckGuardTop(0, pose.headPos.y() + pose.headR * 0.03f,
+                           -pose.headR * 0.82f);
+    QVector3D neckGuardBot(0, pose.headPos.y() - pose.headR * 0.32f,
+                           -pose.headR * 0.88f);
     out.mesh(
         getUnitCylinder(),
-        cylinderBetween(ctx.model, neckGuardBot, neckGuardTop, helmetR * 0.85f),
-        helmetColor * 0.92f, nullptr, 1.0f);
+        cylinderBetween(ctx.model, neckGuardBot, neckGuardTop, helmetR * 0.88f),
+        helmetColor * 0.93f, nullptr, 1.0f);
 
+    // More prominent crest/plume holder
     QVector3D crestBase = apexPos;
-    QVector3D crestTop = crestBase + QVector3D(0, 0.08f, 0);
+    QVector3D crestMid = crestBase + QVector3D(0, 0.09f, 0);
+    QVector3D crestTop = crestMid + QVector3D(0, 0.12f, 0);
+    
+    // Metallic base
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, crestBase, crestTop, 0.015f),
-             helmetColor * 1.15f, nullptr, 1.0f);
-
+             cylinderBetween(ctx.model, crestBase, crestMid, 0.018f),
+             helmetAccent, nullptr, 1.0f);
+    
+    // Decorative plume
     out.mesh(getUnitCone(),
-             coneFromTo(ctx.model, crestTop, crestTop + QVector3D(0, 0.10f, 0),
-                        0.035f),
-             QVector3D(0.85f, 0.15f, 0.15f), nullptr, 1.0f);
+             coneFromTo(ctx.model, crestMid, crestTop, 0.042f),
+             QVector3D(0.88f, 0.18f, 0.18f), nullptr, 1.0f);
+    
+    // Small ornamental tip
+    out.mesh(getUnitSphere(), 
+             sphereAt(ctx.model, crestTop, 0.020f),
+             helmetAccent, nullptr, 1.0f);
   }
 
   void drawArmorOverlay(const DrawContext &ctx, const HumanoidVariant &v,
@@ -392,10 +382,8 @@ public:
     QVector3D mailColor = v.palette.metal * QVector3D(0.85f, 0.87f, 0.92f);
     QVector3D leatherTrim = v.palette.leatherDark * 0.90f;
 
-    // Use pose.pelvisPos.y() instead of hardcoded HP::WAIST_Y
-    // so armor follows the body when kneeling
     float waistY = pose.pelvisPos.y();
-    
+
     QVector3D mailTop(0, yTopCover + 0.01f, 0);
     QVector3D mailMid(0, (yTopCover + waistY) * 0.5f, 0);
     QVector3D mailBot(0, waistY + 0.08f, 0);
@@ -456,7 +444,6 @@ public:
     drawManica(pose.shoulderL, pose.elbowL);
     drawManica(pose.shoulderR, pose.elbowR);
 
-    // Belt follows pelvis position (not hardcoded WAIST_Y)
     QVector3D beltTop(0, waistY + 0.06f, 0);
     QVector3D beltBot(0, waistY - 0.02f, 0);
     float beltR = torsoR * 1.12f;
@@ -465,8 +452,7 @@ public:
              nullptr, 1.0f);
 
     QVector3D brassColor = v.palette.metal * QVector3D(1.2f, 1.0f, 0.65f);
-    ring(QVector3D(0, waistY + 0.02f, 0), beltR * 1.02f, 0.010f,
-         brassColor);
+    ring(QVector3D(0, waistY + 0.02f, 0), beltR * 1.02f, 0.010f, brassColor);
 
     auto drawPteruge = [&](float angle, float yStart, float length) {
       float rad = torsoR * 1.15f;
@@ -484,7 +470,6 @@ public:
       drawPteruge(angle, shoulderPterugeY, 0.14f);
     }
 
-    // Waist pteruges follow pelvis position
     float waistPterugeY = waistY - 0.04f;
     for (int i = 0; i < 10; ++i) {
       float angle = (i / 10.0f) * 2.0f * 3.14159265f;
@@ -535,15 +520,12 @@ public:
 
 private:
   static void drawQuiver(const DrawContext &ctx, const HumanoidVariant &v,
-                         const HumanoidPose &pose,
-                         const ArcherExtras &extras, uint32_t seed,
-                         ISubmitter &out) {
+                         const HumanoidPose &pose, const ArcherExtras &extras,
+                         uint32_t seed, ISubmitter &out) {
     using HP = HumanProportions;
 
-    // SPINE-ANCHORED QUIVER: Position relative to shoulder midpoint (spine)
-    // with stable left/back offset that doesn't drift in hold mode
     QVector3D spineMid = (pose.shoulderL + pose.shoulderR) * 0.5f;
-    QVector3D quiverOffset(-0.08f, 0.10f, -0.25f);  // Stable left/back offset
+    QVector3D quiverOffset(-0.08f, 0.10f, -0.25f);
     QVector3D qTop = spineMid + quiverOffset;
     QVector3D qBase = qTop + QVector3D(-0.02f, -0.30f, 0.03f);
 
@@ -578,10 +560,8 @@ private:
     const QVector3D forward(0.0f, 0.0f, 1.0f);
 
     QVector3D grip = pose.handL;
-    
-    // BOW STAYS IN CONSISTENT VERTICAL PLANE
-    // Ends are vertically aligned at fixed Z (slightly forward from body center)
-    float bowPlaneZ = 0.45f;  // Fixed Z position - bow always faces forward
+
+    float bowPlaneZ = 0.45f;
     QVector3D topEnd(extras.bowX, extras.bowTopY, bowPlaneZ);
     QVector3D botEnd(extras.bowX, extras.bowBotY, bowPlaneZ);
 
@@ -596,20 +576,13 @@ private:
       float u = 1.0f - t;
       return u * u * a + 2.0f * u * t * c + t * t * b;
     };
-    
-    // SKY-FACING BOW CURVE: Move control point toward +Y (upward) AND slightly +Z (depth)
-    // The bow curves upward by pushing the control point HIGH above the midpoint
-    // Also maintain some forward depth for realistic bow shape
+
     float bowMidY = (topEnd.y() + botEnd.y()) * 0.5f;
-    
-    // Push control point SIGNIFICANTLY upward to create sky-facing arc
-    // The higher ctrlY, the more the bow points up toward sky
-    float ctrlY = bowMidY + 0.45f;  // Strong upward bias for sky-facing
-    
-    // Control point: high in Y (sky), modest forward in Z (depth)
-    // Prioritize upward over forward
+
+    float ctrlY = bowMidY + 0.45f;
+
     QVector3D ctrl(extras.bowX, ctrlY, bowPlaneZ + extras.bowDepth * 0.6f);
-    
+
     QVector3D prev = botEnd;
     for (int i = 1; i <= segs; ++i) {
       float t = float(i) / float(segs);

+ 167 - 132
render/entity/spearman_renderer.cpp

@@ -54,12 +54,9 @@ static inline float lerp(float a, float b, float t) {
 struct SpearmanExtras {
   QVector3D spearShaftColor;
   QVector3D spearheadColor;
-  QVector3D shieldColor;
   float spearLength = 1.20f;
   float spearShaftRadius = 0.020f;
   float spearheadLength = 0.18f;
-  float shieldRadius = 0.16f;
-  bool hasShield = true;
 };
 
 class SpearmanRenderer : public HumanoidRendererBase {
@@ -85,46 +82,142 @@ public:
     float armHeightJitter = (hash01(seed ^ 0xABCDu) - 0.5f) * 0.03f;
     float armAsymmetry = (hash01(seed ^ 0xDEF0u) - 0.5f) * 0.04f;
 
-    if (anim.isAttacking && anim.isMelee) {
+    // Hold mode: defensive stance with braced spear (pike square formation)
+    if (anim.isInHoldMode || anim.isExitingHold) {
+      float t = anim.isInHoldMode ? 1.0f : (1.0f - anim.holdExitProgress);
+
+      // Kneel down into defensive position
+      float kneelDepth = 0.35f * t;
+      float pelvisY = HP::WAIST_Y - kneelDepth;
+      pose.pelvisPos.setY(pelvisY);
+
+      // Narrow stance for stability
+      float stanceNarrow = 0.10f;
+
+      // Left knee on ground (kneeling)
+      float leftKneeY = HP::GROUND_Y + 0.06f * t;
+      float leftKneeZ = -0.08f * t;
+      pose.kneeL = QVector3D(-stanceNarrow, leftKneeY, leftKneeZ);
+      pose.footL = QVector3D(-stanceNarrow - 0.02f, HP::GROUND_Y,
+                             leftKneeZ - HP::LOWER_LEG_LEN * 0.90f * t);
+
+      // Right leg bent but supporting weight
+      float rightKneeY = HP::WAIST_Y * 0.45f * (1.0f - t) + HP::WAIST_Y * 0.30f * t;
+      pose.kneeR = QVector3D(stanceNarrow + 0.05f, rightKneeY, 0.15f * t);
+      pose.footR = QVector3D(stanceNarrow + 0.08f, HP::GROUND_Y, 0.25f * t);
+
+      // PROPERLY lower the upper body (shoulders, neck, head) - don't stretch torso!
+      float upperBodyDrop = kneelDepth;
+      pose.shoulderL.setY(HP::SHOULDER_Y - upperBodyDrop);
+      pose.shoulderR.setY(HP::SHOULDER_Y - upperBodyDrop);
+      pose.neckBase.setY(HP::NECK_BASE_Y - upperBodyDrop);
+      
+      // Head and chin need to be lowered together
+      // The neck in humanoid_base draws from neckBase to HP::CHIN_Y (hardcoded)
+      // So we need to position the head such that its bottom (chin) is at the lowered chin position
+      float loweredChinY = HP::CHIN_Y - upperBodyDrop;
+      // Head center should be: chin + headRadius
+      pose.headPos.setY(loweredChinY + pose.headR);
+
+      // Slight forward lean for defensive posture
+      float forwardLean = 0.08f * t;
+      pose.shoulderL.setZ(pose.shoulderL.z() + forwardLean);
+      pose.shoulderR.setZ(pose.shoulderR.z() + forwardLean);
+      pose.neckBase.setZ(pose.neckBase.z() + forwardLean * 0.8f);
+      pose.headPos.setZ(pose.headPos.z() + forwardLean * 0.7f);
+
+      // PROPER DEFENSIVE SPEAR GRIP (triangular arm frame)
+      // Spear angled 30-45° forward, butt at waist level (not ground)
+      
+      // Hand positions need to account for lowered shoulders!
+      float loweredShoulderY = HP::SHOULDER_Y - upperBodyDrop;
+      
+      // Rear hand (right/dominant): Grips spear near butt at waist level
+      pose.handR = QVector3D(
+          0.18f * (1.0f - t) + 0.22f * t,           // X: slightly right of center
+          loweredShoulderY * (1.0f - t) + (pelvisY + 0.05f) * t,  // Y: transitions to just above lowered waist
+          0.15f * (1.0f - t) + 0.20f * t            // Z: close to body
+      );
+
+      // Front hand (left): Grips shaft forward, slightly lower than shoulder
+      pose.handL = QVector3D(
+          0.0f,          // X: turned more inward toward body
+          loweredShoulderY * (1.0f - t) + (loweredShoulderY - 0.10f) * t,  // Y: slightly below lowered shoulder
+          0.30f * (1.0f - t) + 0.55f * t            // Z: forward but not too extended
+      );
+
+      // Fix BOTH elbow positions - force them DOWN not UP
+      // Right arm
+      QVector3D shoulderToHandR = pose.handR - pose.shoulderR;
+      float armLengthR = shoulderToHandR.length();
+      QVector3D armDirR = shoulderToHandR.normalized();
+      pose.elbowR = pose.shoulderR + armDirR * (armLengthR * 0.5f) + QVector3D(0.08f, -0.15f, -0.05f);
+      
+      // Left arm
+      QVector3D shoulderToHandL = pose.handL - pose.shoulderL;
+      float armLengthL = shoulderToHandL.length();
+      QVector3D armDirL = shoulderToHandL.normalized();
+      pose.elbowL = pose.shoulderL + armDirL * (armLengthL * 0.5f) + QVector3D(-0.08f, -0.12f, 0.05f);
+
+    } else if (anim.isAttacking && anim.isMelee && !anim.isInHoldMode) {
       const float attackCycleTime = 0.8f;
       float attackPhase = std::fmod(anim.time * (1.0f / attackCycleTime), 1.0f);
 
-      QVector3D guardPos(0.25f, HP::SHOULDER_Y + 0.10f, 0.20f);
-      QVector3D preparePos(0.30f, HP::SHOULDER_Y + 0.35f, -0.10f);
-      QVector3D thrustPos(0.30f, HP::SHOULDER_Y + 0.15f, 0.80f);
-      QVector3D recoverPos(0.25f, HP::SHOULDER_Y + 0.05f, 0.35f);
+      // Spear thrust positions - keep hands at shoulder height for horizontal thrust
+      QVector3D guardPos(0.28f, HP::SHOULDER_Y + 0.05f, 0.25f);
+      QVector3D preparePos(0.35f, HP::SHOULDER_Y + 0.08f, 0.05f);  // Pull back slightly
+      QVector3D thrustPos(0.32f, HP::SHOULDER_Y + 0.10f, 0.90f);   // Thrust forward horizontally
+      QVector3D recoverPos(0.28f, HP::SHOULDER_Y + 0.06f, 0.40f);
 
       if (attackPhase < 0.20f) {
+        // Preparation: pull spear back
         float t = easeInOutCubic(attackPhase / 0.20f);
         pose.handR = guardPos * (1.0f - t) + preparePos * t;
-        pose.handL = QVector3D(-0.20f, HP::SHOULDER_Y - 0.05f, 0.15f);
+        // Left hand stays on shaft for stability
+        pose.handL = QVector3D(-0.10f, HP::SHOULDER_Y - 0.05f, 0.20f * (1.0f - t) + 0.08f * t);
       } else if (attackPhase < 0.30f) {
+        // Hold at peak preparation
         pose.handR = preparePos;
-        pose.handL = QVector3D(-0.20f, HP::SHOULDER_Y - 0.05f, 0.15f);
+        pose.handL = QVector3D(-0.10f, HP::SHOULDER_Y - 0.05f, 0.08f);
       } else if (attackPhase < 0.50f) {
+        // Explosive thrust forward
         float t = (attackPhase - 0.30f) / 0.20f;
-        t = t * t * t;
+        t = t * t * t;  // Sharp acceleration
         pose.handR = preparePos * (1.0f - t) + thrustPos * t;
+        // Left hand pushes forward on shaft
         pose.handL =
-            QVector3D(-0.20f, HP::SHOULDER_Y - 0.05f * (1.0f - t * 0.5f),
-                      0.15f + 0.15f * t);
+            QVector3D(-0.10f + 0.05f * t, 
+                      HP::SHOULDER_Y - 0.05f + 0.03f * t,
+                      0.08f + 0.45f * t);
       } else if (attackPhase < 0.70f) {
+        // Retract spear
         float t = easeInOutCubic((attackPhase - 0.50f) / 0.20f);
         pose.handR = thrustPos * (1.0f - t) + recoverPos * t;
-        pose.handL = QVector3D(-0.20f, HP::SHOULDER_Y - 0.025f * (1.0f - t),
-                               lerp(0.30f, 0.18f, t));
+        pose.handL = QVector3D(-0.05f * (1.0f - t) - 0.10f * t,
+                               HP::SHOULDER_Y - 0.02f * (1.0f - t) - 0.06f * t,
+                               lerp(0.53f, 0.35f, t));
       } else {
+        // Return to guard stance
         float t = smoothstep(0.70f, 1.0f, attackPhase);
         pose.handR = recoverPos * (1.0f - t) + guardPos * t;
-        pose.handL = QVector3D(-0.20f - 0.02f * (1.0f - t),
-                               HP::SHOULDER_Y + armHeightJitter * (1.0f - t),
-                               lerp(0.18f, 0.15f, t));
+        pose.handL = QVector3D(-0.10f - 0.02f * (1.0f - t),
+                               HP::SHOULDER_Y - 0.06f + 0.01f * t + armHeightJitter * (1.0f - t),
+                               lerp(0.35f, 0.25f, t));
       }
     } else {
       pose.handR = QVector3D(0.28f + armAsymmetry,
                              HP::SHOULDER_Y - 0.02f + armHeightJitter, 0.30f);
-      pose.handL = QVector3D(-0.22f - 0.5f * armAsymmetry,
-                             HP::SHOULDER_Y + 0.5f * armHeightJitter, 0.18f);
+      // Position left hand to grip spear shaft - more forward and inward
+      pose.handL = QVector3D(-0.08f - 0.5f * armAsymmetry,
+                             HP::SHOULDER_Y - 0.08f + 0.5f * armHeightJitter, 0.45f);
+      
+      // Fix elbow for normal stance - force it DOWN not UP
+      QVector3D shoulderToHand = pose.handR - pose.shoulderR;
+      float armLength = shoulderToHand.length();
+      QVector3D armDir = shoulderToHand.normalized();
+      
+      // Position elbow along the arm but bent DOWNWARD (negative Y offset)
+      pose.elbowR = pose.shoulderR + armDir * (armLength * 0.5f) + QVector3D(0.06f, -0.12f, -0.04f);
     }
   }
 
@@ -153,10 +246,8 @@ public:
       attackPhase = std::fmod(anim.time * (1.0f / attackCycleTime), 1.0f);
     }
 
-    drawSpear(ctx, pose, v, extras, isAttacking, attackPhase, out);
-    if (extras.hasShield) {
-      drawShield(ctx, pose, v, extras, out);
-    }
+    drawSpear(ctx, pose, v, extras, anim, isAttacking, attackPhase, out);
+    // Shields removed - spearmen use two-handed spear grip
   }
 
   void drawHelmet(const DrawContext &ctx, const HumanoidVariant &v,
@@ -166,14 +257,15 @@ public:
     QVector3D ironColor = v.palette.metal * QVector3D(0.88f, 0.90f, 0.92f);
 
     float helmR = pose.headR * 1.12f;
-    QVector3D helmBot(0, pose.headPos.y() - pose.headR * 0.15f, 0);
-    QVector3D helmTop(0, pose.headPos.y() + pose.headR * 1.25f, 0);
+    // Use pose.headPos for X, Y, AND Z so helmet follows head lean/tilt
+    QVector3D helmBot(pose.headPos.x(), pose.headPos.y() - pose.headR * 0.15f, pose.headPos.z());
+    QVector3D helmTop(pose.headPos.x(), pose.headPos.y() + pose.headR * 1.25f, pose.headPos.z());
 
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, helmBot, helmTop, helmR), ironColor,
              nullptr, 1.0f);
 
-    QVector3D capTop(0, pose.headPos.y() + pose.headR * 1.32f, 0);
+    QVector3D capTop(pose.headPos.x(), pose.headPos.y() + pose.headR * 1.32f, pose.headPos.z());
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, helmTop, capTop, helmR * 0.96f),
              ironColor * 1.04f, nullptr, 1.0f);
@@ -186,18 +278,18 @@ public:
                nullptr, 1.0f);
     };
 
-    ring(QVector3D(0, pose.headPos.y() + pose.headR * 0.95f, 0), helmR * 1.01f,
+    ring(QVector3D(pose.headPos.x(), pose.headPos.y() + pose.headR * 0.95f, pose.headPos.z()), helmR * 1.01f,
          0.012f, ironColor * 1.06f);
-    ring(QVector3D(0, pose.headPos.y() - pose.headR * 0.02f, 0), helmR * 1.01f,
+    ring(QVector3D(pose.headPos.x(), pose.headPos.y() - pose.headR * 0.02f, pose.headPos.z()), helmR * 1.01f,
          0.012f, ironColor * 1.06f);
 
     float visorY = pose.headPos.y() + pose.headR * 0.10f;
-    float visorZ = helmR * 0.68f;
+    float visorZ = pose.headPos.z() + helmR * 0.68f;
 
     for (int i = 0; i < 3; ++i) {
       float y = visorY + pose.headR * (0.18f - i * 0.12f);
-      QVector3D visorL(-helmR * 0.30f, y, visorZ);
-      QVector3D visorR(helmR * 0.30f, y, visorZ);
+      QVector3D visorL(pose.headPos.x() - helmR * 0.30f, y, visorZ);
+      QVector3D visorR(pose.headPos.x() + helmR * 0.30f, y, visorZ);
       out.mesh(getUnitCylinder(),
                cylinderBetween(ctx.model, visorL, visorR, 0.010f),
                QVector3D(0.15f, 0.15f, 0.15f), nullptr, 1.0f);
@@ -267,8 +359,7 @@ public:
       QVector3D stripTop(0, y, 0);
       QVector3D stripBot(0, y - 0.030f, 0);
 
-      out.mesh(getUnitCone(),
-               coneFromTo(ctx.model, stripTop, stripBot, r),
+      out.mesh(getUnitCone(), coneFromTo(ctx.model, stripTop, stripBot, r),
                leatherColor * (0.98f - i * 0.02f), nullptr, 1.0f);
     }
   }
@@ -276,8 +367,7 @@ public:
   void drawShoulderDecorations(const DrawContext &ctx, const HumanoidVariant &v,
                                const HumanoidPose &pose, float yTopCover,
                                float yNeck, const QVector3D &rightAxis,
-                               ISubmitter &out) const override {
-  }
+                               ISubmitter &out) const override {}
 
 private:
   static SpearmanExtras computeSpearmanExtras(uint32_t seed,
@@ -287,52 +377,73 @@ private:
     e.spearShaftColor = v.palette.leather * QVector3D(0.85f, 0.75f, 0.65f);
     e.spearheadColor = QVector3D(0.75f, 0.76f, 0.80f);
 
-    float shieldHue = hash01(seed ^ 0x12345u);
-    if (shieldHue < 0.50f) {
-      e.shieldColor = v.palette.cloth * 1.05f;
-    } else {
-      e.shieldColor = v.palette.leather * 1.20f;
-    }
-
     e.spearLength = 1.15f + (hash01(seed ^ 0xABCDu) - 0.5f) * 0.10f;
     e.spearShaftRadius = 0.018f + (hash01(seed ^ 0x7777u) - 0.5f) * 0.003f;
     e.spearheadLength = 0.16f + (hash01(seed ^ 0xBEEFu) - 0.5f) * 0.04f;
-    e.shieldRadius = 0.15f + (hash01(seed ^ 0xDEF0u) - 0.5f) * 0.03f;
 
-    e.hasShield = (hash01(seed ^ 0x5555u) > 0.20f);
     return e;
   }
 
   static void drawSpear(const DrawContext &ctx, const HumanoidPose &pose,
                         const HumanoidVariant &v, const SpearmanExtras &extras,
-                        bool isAttacking, float attackPhase, ISubmitter &out) {
+                        const AnimationInputs &anim, bool isAttacking, 
+                        float attackPhase, ISubmitter &out) {
     QVector3D gripPos = pose.handR;
 
-    QVector3D spearDir = QVector3D(0.08f, 0.15f, 1.0f);
+    // More vertical spear orientation for idle stance (pointing upward/forward)
+    // Realistic spearman hold: spear at ~60-70 degrees from horizontal
+    QVector3D spearDir = QVector3D(0.05f, 0.55f, 0.85f);
     if (spearDir.lengthSquared() > 1e-6f)
       spearDir.normalize();
 
-    if (isAttacking) {
+    // Hold mode: spear braced at 25-35° angle (pike formation defensive stance)
+    if (anim.isInHoldMode || anim.isExitingHold) {
+      float t = anim.isInHoldMode ? 1.0f : (1.0f - anim.holdExitProgress);
+      
+      // Braced spear: butt at waist level, tip aimed forward at enemy chest/face height
+      // ~30° from horizontal (butt raised from ground to waist)
+      // Slight lateral tilt for realism
+      QVector3D bracedDir = QVector3D(0.05f, 0.40f, 0.91f);
+      if (bracedDir.lengthSquared() > 1e-6f)
+        bracedDir.normalize();
+      
+      spearDir = spearDir * (1.0f - t) + bracedDir * t;
+      if (spearDir.lengthSquared() > 1e-6f)
+        spearDir.normalize();
+    } else if (isAttacking) {
       if (attackPhase >= 0.30f && attackPhase < 0.50f) {
         float t = (attackPhase - 0.30f) / 0.20f;
-        QVector3D attackDir = QVector3D(0.05f, 0.02f, 1.0f);
+        // Thrust forward and downward during attack to target enemy torso
+        QVector3D attackDir = QVector3D(0.03f, -0.15f, 1.0f);
         if (attackDir.lengthSquared() > 1e-6f)
           attackDir.normalize();
-        
+
         spearDir = spearDir * (1.0f - t) + attackDir * t;
         if (spearDir.lengthSquared() > 1e-6f)
           spearDir.normalize();
       }
     }
 
-    QVector3D shaftBase = gripPos - spearDir * 0.25f;
+    // Slight curve/bend to spear shaft for realism
+    QVector3D shaftBase = gripPos - spearDir * 0.28f;
+    QVector3D shaftMid = gripPos + spearDir * (extras.spearLength * 0.5f);
     QVector3D shaftTip = gripPos + spearDir * extras.spearLength;
+    
+    // Add slight upward curve to mid-section
+    shaftMid.setY(shaftMid.y() + 0.02f);
 
+    // Draw shaft in two segments for curved effect
     out.mesh(getUnitCylinder(),
-             cylinderBetween(ctx.model, shaftBase, shaftTip,
+             cylinderBetween(ctx.model, shaftBase, shaftMid,
                              extras.spearShaftRadius),
              extras.spearShaftColor, nullptr, 1.0f);
+    
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, shaftMid, shaftTip,
+                             extras.spearShaftRadius * 0.95f),
+             extras.spearShaftColor * 0.98f, nullptr, 1.0f);
 
+    // Spearhead
     QVector3D spearheadBase = shaftTip;
     QVector3D spearheadTip = shaftTip + spearDir * extras.spearheadLength;
 
@@ -341,88 +452,12 @@ private:
                         extras.spearShaftRadius * 1.8f),
              extras.spearheadColor, nullptr, 1.0f);
 
-    QVector3D gripEnd = gripPos + spearDir * 0.08f;
+    // Grip wrap
+    QVector3D gripEnd = gripPos + spearDir * 0.10f;
     out.mesh(getUnitCylinder(),
              cylinderBetween(ctx.model, gripPos, gripEnd,
-                             extras.spearShaftRadius * 1.4f),
-             v.palette.leather, nullptr, 1.0f);
-  }
-
-  static void drawShield(const DrawContext &ctx, const HumanoidPose &pose,
-                         const HumanoidVariant &v, const SpearmanExtras &extras,
-                         ISubmitter &out) {
-    const float scaleFactor = 2.2f;
-    const float R = extras.shieldRadius * scaleFactor;
-
-    const float yawDeg = -65.0f;
-    QMatrix4x4 rot;
-    rot.rotate(yawDeg, 0.0f, 1.0f, 0.0f);
-
-    const QVector3D n = rot.map(QVector3D(0.0f, 0.0f, 1.0f));
-    const QVector3D axisX = rot.map(QVector3D(1.0f, 0.0f, 0.0f));
-    const QVector3D axisY = rot.map(QVector3D(0.0f, 1.0f, 0.0f));
-
-    QVector3D shieldCenter =
-        pose.handL + axisX * (-R * 0.30f) + axisY * (-0.04f) + n * (0.05f);
-
-    const float plateHalf = 0.0012f;
-    const float plateFull = plateHalf * 2.0f;
-
-    {
-      QMatrix4x4 m = ctx.model;
-      m.translate(shieldCenter + n * plateHalf);
-      m.rotate(yawDeg, 0.0f, 1.0f, 0.0f);
-      m.scale(R, R, plateFull);
-      out.mesh(getUnitCylinder(), m, extras.shieldColor, nullptr, 1.0f);
-    }
-
-    {
-      QMatrix4x4 m = ctx.model;
-      m.translate(shieldCenter - n * plateHalf);
-      m.rotate(yawDeg, 0.0f, 1.0f, 0.0f);
-      m.scale(R * 0.985f, R * 0.985f, plateFull);
-      out.mesh(getUnitCylinder(), m, v.palette.leather * 0.85f, nullptr, 1.0f);
-    }
-
-    auto drawRingRotated = [&](float radius, float thickness,
-                               const QVector3D &color) {
-      const int segments = 16;
-      for (int i = 0; i < segments; ++i) {
-        float a0 = (float)i / segments * 2.0f * 3.14159265f;
-        float a1 = (float)(i + 1) / segments * 2.0f * 3.14159265f;
-
-        QVector3D v0 =
-            QVector3D(radius * std::cos(a0), radius * std::sin(a0), 0.0f);
-        QVector3D v1 =
-            QVector3D(radius * std::cos(a1), radius * std::sin(a1), 0.0f);
-
-        QVector3D p0 = shieldCenter + rot.map(v0);
-        QVector3D p1 = shieldCenter + rot.map(v1);
-
-        out.mesh(getUnitCylinder(),
-                 cylinderBetween(ctx.model, p0, p1, thickness), color, nullptr,
-                 1.0f);
-      }
-    };
-
-    drawRingRotated(R, 0.008f * scaleFactor,
-                    QVector3D(0.78f, 0.79f, 0.83f) * 0.95f);
-
-    {
-      QMatrix4x4 m = ctx.model;
-      m.translate(shieldCenter + n * (0.018f * scaleFactor));
-      m.scale(0.038f * scaleFactor);
-      out.mesh(getUnitSphere(), m, QVector3D(0.76f, 0.77f, 0.81f), nullptr,
-               1.0f);
-    }
-
-    {
-      QVector3D gripA = shieldCenter - axisX * 0.030f - n * 0.025f;
-      QVector3D gripB = shieldCenter + axisX * 0.030f - n * 0.025f;
-      out.mesh(getUnitCylinder(),
-               cylinderBetween(ctx.model, gripA, gripB, 0.008f),
-               v.palette.leather, nullptr, 1.0f);
-    }
+                             extras.spearShaftRadius * 1.5f),
+             v.palette.leather * 0.92f, nullptr, 1.0f);
   }
 };
 

+ 1 - 3
render/ground/pine_renderer.cpp

@@ -93,7 +93,6 @@ void PineRenderer::submit(Renderer &renderer, ResourceManager *resources) {
     return;
   }
 
-  // Filter instances based on fog of war visibility
   auto &visibility = Game::Map::VisibilityService::instance();
   const bool useVisibility = visibility.isInitialized();
 
@@ -120,8 +119,7 @@ void PineRenderer::submit(Renderer &renderer, ResourceManager *resources) {
   if (!m_pineInstanceBuffer) {
     m_pineInstanceBuffer = std::make_unique<Buffer>(Buffer::Type::Vertex);
   }
-  
-  // Always update buffer with visible instances
+
   m_pineInstanceBuffer->setData(visibleInstances, Buffer::Usage::Static);
 
   PineBatchParams params = m_pineParams;

+ 10 - 9
render/humanoid_base.cpp

@@ -92,6 +92,10 @@ AnimationInputs HumanoidRendererBase::sampleAnimState(const DrawContext &ctx) {
   if (!ctx.entity)
     return anim;
 
+  // Early exit if entity is pending removal to avoid accessing corrupted components
+  if (ctx.entity->hasComponent<Engine::Core::PendingRemovalComponent>())
+    return anim;
+
   auto *movement = ctx.entity->getComponent<Engine::Core::MovementComponent>();
   auto *attack = ctx.entity->getComponent<Engine::Core::AttackComponent>();
   auto *attackTarget =
@@ -176,10 +180,8 @@ void HumanoidRendererBase::computeLocomotionPose(
   pose.footR = QVector3D(HP::SHOULDER_WIDTH * 0.58f * sWidth,
                          HP::GROUND_Y + pose.footYOffset, 0.0f);
 
-  // Initialize pelvis at waist height by default
   pose.pelvisPos = QVector3D(0.0f, HP::WAIST_Y * hScale, 0.0f);
 
-  // Initialize knees at default standing position
   pose.kneeL = QVector3D(pose.footL.x(), HP::KNEE_Y * hScale, pose.footL.z());
   pose.kneeR = QVector3D(pose.footR.x(), HP::KNEE_Y * hScale, pose.footR.z());
 
@@ -280,13 +282,15 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
   const float yTopCover = std::max(yShoulder + 0.04f, yNeck + 0.00f);
 
   QVector3D tunicTop{0.0f, yTopCover - 0.006f, 0.0f};
-  // Use pelvisPos instead of hardcoded WAIST_Y so torso follows when kneeling
+
   QVector3D tunicBot{0.0f, pose.pelvisPos.y() + 0.03f, 0.0f};
   out.mesh(getUnitTorso(),
            cylinderBetween(ctx.model, tunicTop, tunicBot, torsoR),
            v.palette.cloth, nullptr, 1.0f);
 
-  QVector3D chinPos{0.0f, HP::CHIN_Y, 0.0f};
+  // Chin position should be at the bottom of the head sphere, not a fixed constant
+  // This ensures neck connects properly even when head is lowered (e.g., kneeling pose)
+  QVector3D chinPos{0.0f, pose.headPos.y() - pose.headR, 0.0f};
   out.mesh(getUnitCylinder(),
            cylinderBetween(ctx.model, pose.neckBase, chinPos,
                            HP::NECK_RADIUS * widthScale),
@@ -355,7 +359,6 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
 
   constexpr float DEG = 3.1415926535f / 180.f;
 
-  // Use pose.pelvisPos instead of hardcoded waist for proper kneeling
   const QVector3D hipL = pose.pelvisPos + QVector3D(-hipHalf, 0.f, 0.f);
   const QVector3D hipR = pose.pelvisPos + QVector3D(+hipHalf, 0.f, 0.f);
   const float midX = 0.5f * (hipL.x() + hipR.x());
@@ -396,12 +399,10 @@ void HumanoidRendererBase::drawCommonBody(const DrawContext &ctx,
   const float kneeForwardPush = HP::LOWER_LEG_LEN * kneeForward;
   const float kneeDropAbs = kneeDrop;
 
-  // Use pose's knee positions if they've been customized (e.g., for kneeling)
-  // Otherwise compute default standing knees
   QVector3D kneeL, kneeR;
-  bool useCustomKnees = (pose.kneeL.y() < HP::KNEE_Y * 0.9f || 
+  bool useCustomKnees = (pose.kneeL.y() < HP::KNEE_Y * 0.9f ||
                          pose.kneeR.y() < HP::KNEE_Y * 0.9f);
-  
+
   if (useCustomKnees) {
     kneeL = pose.kneeL;
     kneeR = pose.kneeR;

+ 1 - 2
render/humanoid_base.h

@@ -21,7 +21,7 @@ struct AnimationInputs {
   bool isAttacking;
   bool isMelee;
   bool isInHoldMode;
-  bool isExitingHold;  // True during stand-up transition
+  bool isExitingHold;
   float holdExitProgress;
 };
 
@@ -40,7 +40,6 @@ struct HumanoidPose {
   QVector3D elbowL, elbowR;
   QVector3D handL, handR;
 
-  // Pelvis/hip position for proper leg articulation
   QVector3D pelvisPos;
   QVector3D kneeL, kneeR;
 

+ 8 - 5
render/scene_renderer.cpp

@@ -226,7 +226,9 @@ void Renderer::renderWorld(Engine::Core::World *world) {
   if (!world)
     return;
 
-  std::lock_guard<std::mutex> guard(m_worldMutex);
+  // Lock World's recursive mutex for entire render to prevent entities from being
+  // deleted while we iterate. Recursive mutex allows World methods to lock again.
+  std::lock_guard<std::recursive_mutex> guard(world->getEntityMutex());
 
   auto &vis = Game::Map::VisibilityService::instance();
   const bool visibilityEnabled = vis.isInitialized();
@@ -235,6 +237,11 @@ void Renderer::renderWorld(Engine::Core::World *world) {
       world->getEntitiesWith<Engine::Core::RenderableComponent>();
 
   for (auto entity : renderableEntities) {
+    // Skip entities pending removal first, before accessing any components
+    if (entity->hasComponent<Engine::Core::PendingRemovalComponent>()) {
+      continue;
+    }
+
     auto renderable = entity->getComponent<Engine::Core::RenderableComponent>();
     auto transform = entity->getComponent<Engine::Core::TransformComponent>();
 
@@ -242,10 +249,6 @@ void Renderer::renderWorld(Engine::Core::World *world) {
       continue;
     }
 
-    if (entity->hasComponent<Engine::Core::PendingRemovalComponent>()) {
-      continue;
-    }
-
     auto *unitComp = entity->getComponent<Engine::Core::UnitComponent>();
     if (unitComp && unitComp->health <= 0) {
       continue;

+ 1 - 1
ui/qml/HUD.qml

@@ -24,7 +24,7 @@ Item {
             selectionTick += 1;
             var hasTroops = false;
             if (typeof game !== 'undefined' && game.hasUnitsSelected && game.hasSelectedType) {
-                var troopTypes = ["warrior", "archer"];
+                var troopTypes = ["warrior", "archer", "knight", "spearman"];
                 for (var i = 0; i < troopTypes.length; i++) {
                     if (game.hasSelectedType(troopTypes[i])) {
                         hasTroops = true;

+ 25 - 12
ui/qml/HUDBottom.qml

@@ -307,6 +307,7 @@ RowLayout {
                 onClicked: {
                     if (typeof game !== 'undefined' && game.onStopCommand)
                         game.onStopCommand();
+
                 }
                 ToolTip.visible: hovered
                 ToolTip.text: bottomRoot.hasMovableUnits ? "Stop all actions immediately" : "Select troops first"
@@ -332,36 +333,48 @@ RowLayout {
 
             Button {
                 id: holdButton
+
                 Layout.fillWidth: true
                 Layout.preferredHeight: 38
                 text: "Hold"
                 focusPolicy: Qt.NoFocus
                 enabled: bottomRoot.hasMovableUnits
                 
-                property bool isHoldActive: (bottomRoot.selectionTick, (typeof game !== 'undefined' && game.anySelectedInHoldMode) ? game.anySelectedInHoldMode() : false)
-                
+                property bool isHoldActive: {
+                    bottomRoot.selectionTick;
+                    return (typeof game !== 'undefined' && game.anySelectedInHoldMode) ? game.anySelectedInHoldMode() : false;
+                }
                 onClicked: {
                     if (typeof game !== 'undefined' && game.onHoldCommand)
                         game.onHoldCommand();
+
                 }
-                
+                ToolTip.visible: hovered
+                ToolTip.text: bottomRoot.hasMovableUnits ? (isHoldActive ? "Exit hold mode (toggle)" : "Hold position and defend") : "Select troops first"
+                ToolTip.delay: 500
+
                 Connections {
-                    target: (typeof game !== 'undefined') ? game : null
                     function onHoldModeChanged(active) {
                         holdButton.isHoldActive = (typeof game !== 'undefined' && game.anySelectedInHoldMode) ? game.anySelectedInHoldMode() : false;
                     }
+
+                    target: (typeof game !== 'undefined') ? game : null
                 }
-                
-                ToolTip.visible: hovered
-                ToolTip.text: bottomRoot.hasMovableUnits ? (isHoldActive ? "Exit hold mode (toggle)" : "Hold position and defend") : "Select troops first"
-                ToolTip.delay: 500
 
                 background: Rectangle {
                     color: {
-                        if (!parent.enabled) return "#1a252f";
-                        if (parent.isHoldActive) return "#8e44ad";  // Active purple
-                        if (parent.pressed) return "#8e44ad";
-                        if (parent.hovered) return "#9b59b6";
+                        if (!parent.enabled)
+                            return "#1a252f";
+
+                        if (parent.isHoldActive)
+                            return "#8e44ad";
+
+                        if (parent.pressed)
+                            return "#8e44ad";
+
+                        if (parent.hovered)
+                            return "#9b59b6";
+
                         return "#34495e";
                     }
                     radius: 6