فهرست منبع

Add Horse Archer and Horse Spearman units with renderers

djeada 1 ماه پیش
والد
کامیت
2cfb4f3fa2
64فایلهای تغییر یافته به همراه4251 افزوده شده و 39 حذف شده
  1. 37 0
      CMakeLists.txt
  2. 2 0
      app/core/game_engine.cpp
  3. 19 0
      assets.qrc
  4. 70 0
      assets/data/troops/base.json
  5. 355 0
      assets/shaders/horse_archer_carthage.frag
  6. 33 0
      assets/shaders/horse_archer_carthage.vert
  7. 354 0
      assets/shaders/horse_archer_kingdom_of_iron.frag
  8. 32 0
      assets/shaders/horse_archer_kingdom_of_iron.vert
  9. 353 0
      assets/shaders/horse_archer_roman_republic.frag
  10. 32 0
      assets/shaders/horse_archer_roman_republic.vert
  11. 355 0
      assets/shaders/horse_spearman_carthage.frag
  12. 33 0
      assets/shaders/horse_spearman_carthage.vert
  13. 354 0
      assets/shaders/horse_spearman_kingdom_of_iron.frag
  14. 32 0
      assets/shaders/horse_spearman_kingdom_of_iron.vert
  15. 353 0
      assets/shaders/horse_spearman_roman_republic.frag
  16. 32 0
      assets/shaders/horse_spearman_roman_republic.vert
  17. BIN
      assets/visuals/icons/archer_cartaghe.png
  18. BIN
      assets/visuals/icons/archer_rome.png
  19. BIN
      assets/visuals/icons/ballista_cartaghe.png
  20. BIN
      assets/visuals/icons/ballista_rome.png
  21. BIN
      assets/visuals/icons/catapult_cartaghe.png
  22. BIN
      assets/visuals/icons/catapult_rome.png
  23. BIN
      assets/visuals/icons/healer_cartaghe.png
  24. BIN
      assets/visuals/icons/healer_rome.png
  25. BIN
      assets/visuals/icons/horse_archer_cartaghe.png
  26. BIN
      assets/visuals/icons/horse_archer_rome.png
  27. BIN
      assets/visuals/icons/horse_spearman_cartaghe.png
  28. BIN
      assets/visuals/icons/horse_spearman_rome.png
  29. BIN
      assets/visuals/icons/horse_swordsman_cartaghe.png
  30. BIN
      assets/visuals/icons/horse_swordsman_rome.png
  31. BIN
      assets/visuals/icons/spearman_cartaghe.png
  32. BIN
      assets/visuals/icons/spearman_rome.png
  33. BIN
      assets/visuals/icons/swordsman_cartaghe.png
  34. BIN
      assets/visuals/icons/swordsman_rome.png
  35. 2 0
      game/CMakeLists.txt
  36. 5 0
      game/systems/production_service.cpp
  37. 2 0
      game/systems/production_service.h
  38. 12 0
      game/units/factory.cpp
  39. 109 0
      game/units/horse_archer.cpp
  40. 17 0
      game/units/horse_archer.h
  41. 109 0
      game/units/horse_spearman.cpp
  42. 17 0
      game/units/horse_spearman.h
  43. 28 0
      game/units/spawn_type.h
  44. 15 1
      game/units/troop_type.h
  45. 8 0
      render/CMakeLists.txt
  46. 256 0
      render/entity/horse_archer_renderer_base.cpp
  47. 85 0
      render/entity/horse_archer_renderer_base.h
  48. 259 0
      render/entity/horse_spearman_renderer_base.cpp
  49. 85 0
      render/entity/horse_spearman_renderer_base.h
  50. 58 0
      render/entity/nations/carthage/horse_archer_renderer.cpp
  51. 9 0
      render/entity/nations/carthage/horse_archer_renderer.h
  52. 56 0
      render/entity/nations/carthage/horse_spearman_renderer.cpp
  53. 9 0
      render/entity/nations/carthage/horse_spearman_renderer.h
  54. 58 0
      render/entity/nations/kingdom/horse_archer_renderer.cpp
  55. 9 0
      render/entity/nations/kingdom/horse_archer_renderer.h
  56. 56 0
      render/entity/nations/kingdom/horse_spearman_renderer.cpp
  57. 9 0
      render/entity/nations/kingdom/horse_spearman_renderer.h
  58. 58 0
      render/entity/nations/roman/horse_archer_renderer.cpp
  59. 9 0
      render/entity/nations/roman/horse_archer_renderer.h
  60. 56 0
      render/entity/nations/roman/horse_spearman_renderer.cpp
  61. 9 0
      render/entity/nations/roman/horse_spearman_renderer.h
  62. 15 0
      render/entity/registry.cpp
  63. 320 36
      ui/qml/ProductionPanel.qml
  64. 65 2
      ui/qml/StyleGuide.qml

+ 37 - 0
CMakeLists.txt

@@ -199,6 +199,24 @@ if(QT_VERSION_MAJOR EQUAL 6)
             assets/shaders/swordsman.vert
             assets/shaders/horse_swordsman.frag
             assets/shaders/horse_swordsman.vert
+            assets/shaders/horse_swordsman_kingdom_of_iron.frag
+            assets/shaders/horse_swordsman_kingdom_of_iron.vert
+            assets/shaders/horse_swordsman_roman_republic.frag
+            assets/shaders/horse_swordsman_roman_republic.vert
+            assets/shaders/horse_swordsman_carthage.frag
+            assets/shaders/horse_swordsman_carthage.vert
+            assets/shaders/horse_archer_kingdom_of_iron.frag
+            assets/shaders/horse_archer_kingdom_of_iron.vert
+            assets/shaders/horse_archer_roman_republic.frag
+            assets/shaders/horse_archer_roman_republic.vert
+            assets/shaders/horse_archer_carthage.frag
+            assets/shaders/horse_archer_carthage.vert
+            assets/shaders/horse_spearman_kingdom_of_iron.frag
+            assets/shaders/horse_spearman_kingdom_of_iron.vert
+            assets/shaders/horse_spearman_roman_republic.frag
+            assets/shaders/horse_spearman_roman_republic.vert
+            assets/shaders/horse_spearman_carthage.frag
+            assets/shaders/horse_spearman_carthage.vert
             assets/shaders/pine_instanced.frag
             assets/shaders/pine_instanced.vert
             assets/shaders/plant_instanced.frag
@@ -222,8 +240,27 @@ if(QT_VERSION_MAJOR EQUAL 6)
             assets/maps/map_forest.json
             assets/maps/map_rivers.json
             assets/maps/map_mountain.json
+            assets/visuals/unit_visuals.json
             assets/visuals/emblems/rome.png
             assets/visuals/emblems/cartaghe.png
+            assets/visuals/icons/archer_rome.png
+            assets/visuals/icons/archer_cartaghe.png
+            assets/visuals/icons/swordsman_rome.png
+            assets/visuals/icons/swordsman_cartaghe.png
+            assets/visuals/icons/spearman_rome.png
+            assets/visuals/icons/spearman_cartaghe.png
+            assets/visuals/icons/horse_swordsman_rome.png
+            assets/visuals/icons/horse_swordsman_cartaghe.png
+            assets/visuals/icons/horse_archer_rome.png
+            assets/visuals/icons/horse_archer_cartaghe.png
+            assets/visuals/icons/horse_spearman_rome.png
+            assets/visuals/icons/horse_spearman_cartaghe.png
+            assets/visuals/icons/healer_rome.png
+            assets/visuals/icons/healer_cartaghe.png
+            assets/visuals/icons/catapult_rome.png
+            assets/visuals/icons/catapult_cartaghe.png
+            assets/visuals/icons/ballista_rome.png
+            assets/visuals/icons/ballista_cartaghe.png
             translations/app_en.qm
             translations/app_de.qm
             translations/app_pt_br.qm

+ 2 - 0
app/core/game_engine.cpp

@@ -953,6 +953,8 @@ auto GameEngine::getSelectedProductionState() const -> QVariantMap {
   m["maxUnits"] = st.maxUnits;
   m["villagerCost"] = st.villagerCost;
   m["queueSize"] = st.queueSize;
+  m["nation_id"] =
+      QString::fromStdString(Game::Systems::nationIDToString(st.nation_id));
 
   QVariantList queue_list;
   for (const auto &unit_type : st.productionQueue) {

+ 19 - 0
assets.qrc

@@ -64,6 +64,25 @@
         <file>assets/visuals/unit_visuals.json</file>
         <file>assets/visuals/emblems/rome.png</file>
         <file>assets/visuals/emblems/cartaghe.png</file>
+        <!-- Unit icons -->
+        <file>assets/visuals/icons/archer_rome.png</file>
+        <file>assets/visuals/icons/archer_cartaghe.png</file>
+        <file>assets/visuals/icons/swordsman_rome.png</file>
+        <file>assets/visuals/icons/swordsman_cartaghe.png</file>
+        <file>assets/visuals/icons/spearman_rome.png</file>
+        <file>assets/visuals/icons/spearman_cartaghe.png</file>
+        <file>assets/visuals/icons/horse_swordsman_rome.png</file>
+        <file>assets/visuals/icons/horse_swordsman_cartaghe.png</file>
+        <file>assets/visuals/icons/horse_archer_rome.png</file>
+        <file>assets/visuals/icons/horse_archer_cartaghe.png</file>
+        <file>assets/visuals/icons/horse_spearman_rome.png</file>
+        <file>assets/visuals/icons/horse_spearman_cartaghe.png</file>
+        <file>assets/visuals/icons/healer_rome.png</file>
+        <file>assets/visuals/icons/healer_cartaghe.png</file>
+        <file>assets/visuals/icons/catapult_rome.png</file>
+        <file>assets/visuals/icons/catapult_cartaghe.png</file>
+        <file>assets/visuals/icons/ballista_rome.png</file>
+        <file>assets/visuals/icons/ballista_cartaghe.png</file>
 
         <!-- Gameplay data -->
         <file>assets/data/troops/base.json</file>

+ 70 - 0
assets/data/troops/base.json

@@ -139,6 +139,76 @@
         "individuals_per_unit": 9,
         "max_units_per_row": 3
       }
+    },
+    {
+      "id": "horse_archer",
+      "display_name": "Horse Archer",
+      "production": {
+        "cost": 120,
+        "build_time": 9.0,
+        "priority": 12,
+        "is_melee": false
+      },
+      "combat": {
+        "health": 140,
+        "max_health": 140,
+        "speed": 7.5,
+        "vision_range": 18.0,
+        "ranged_range": 7.0,
+        "ranged_damage": 14,
+        "ranged_cooldown": 1.5,
+        "melee_range": 1.8,
+        "melee_damage": 8,
+        "melee_cooldown": 0.9,
+        "can_ranged": true,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.75,
+        "selection_ring_size": 1.8,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 1.2,
+        "renderer_id": "troops/kingdom/horse_archer"
+      },
+      "formation": {
+        "individuals_per_unit": 10,
+        "max_units_per_row": 3
+      }
+    },
+    {
+      "id": "horse_spearman",
+      "display_name": "Horse Spearman",
+      "production": {
+        "cost": 140,
+        "build_time": 9.5,
+        "priority": 13,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 180,
+        "max_health": 180,
+        "speed": 7.8,
+        "vision_range": 16.0,
+        "ranged_range": 1.5,
+        "ranged_damage": 5,
+        "ranged_cooldown": 2.0,
+        "melee_range": 3.0,
+        "melee_damage": 28,
+        "melee_cooldown": 0.9,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.78,
+        "selection_ring_size": 1.9,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 1.3,
+        "renderer_id": "troops/kingdom/horse_spearman"
+      },
+      "formation": {
+        "individuals_per_unit": 9,
+        "max_units_per_row": 3
+      }
     }
   ]
 }

+ 355 - 0
assets/shaders/horse_archer_carthage.frag

@@ -0,0 +1,355 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+in float v_armorLayer; // Armor layer from vertex shader
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+// ---------------------
+// utilities & noise
+// ---------------------
+const float PI = 3.14159265359;
+
+float saturate(float x) { return clamp(x, 0.0, 1.0); }
+vec3 saturate(vec3 x) { return clamp(x, 0.0, 1.0); }
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float fbm(vec2 p) {
+  float a = 0.5;
+  float f = 0.0;
+  for (int i = 0; i < 5; ++i) {
+    f += a * noise(p);
+    p *= 2.03;
+    a *= 0.5;
+  }
+  return f;
+}
+
+// anti-aliased step
+float aaStep(float edge, float x) {
+  float w = fwidth(x);
+  return smoothstep(edge - w, edge + w, x);
+}
+
+// ---------------------
+// patterns
+// ---------------------
+
+// plate seams + rivets (AA)
+float armorPlates(vec2 p, float y) {
+  float plateY = fract(y * 6.5);
+  float line = smoothstep(0.92, 0.98, plateY) - smoothstep(0.98, 1.0, plateY);
+  // anti-aliased line thickness
+  line = smoothstep(0.0, fwidth(plateY) * 2.0, line) * 0.12;
+
+  // rivets on top seams
+  float rivetX = fract(p.x * 18.0);
+  float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
+  rivet *= step(0.92, plateY);
+  return line + rivet * 0.25;
+}
+
+// linked ring suggestion (AA)
+float chainmailRings(vec2 p) {
+  vec2 uv = p * 35.0;
+
+  vec2 g0 = fract(uv) - 0.5;
+  float r0 = length(g0);
+  float fw0 = fwidth(r0) * 1.2;
+  float ring0 = smoothstep(0.30 + fw0, 0.30 - fw0, r0) -
+                smoothstep(0.20 + fw0, 0.20 - fw0, r0);
+
+  vec2 g1 = fract(uv + vec2(0.5, 0.0)) - 0.5;
+  float r1 = length(g1);
+  float fw1 = fwidth(r1) * 1.2;
+  float ring1 = smoothstep(0.30 + fw1, 0.30 - fw1, r1) -
+                smoothstep(0.20 + fw1, 0.20 - fw1, r1);
+
+  return (ring0 + ring1) * 0.15;
+}
+
+float horseHidePattern(vec2 p) {
+  float grain = fbm(p * 80.0) * 0.10;
+  float ripple = sin(p.x * 22.0) * cos(p.y * 28.0) * 0.035;
+  float mottling = smoothstep(0.55, 0.65, fbm(p * 6.0)) * 0.07;
+  return grain + ripple + mottling;
+}
+
+// ---------------------
+// microfacet shading
+// ---------------------
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+}
+
+float D_GGX(float NdotH, float rough) {
+  float a = max(0.001, rough);
+  float a2 = a * a;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(1e-6, (PI * d * d));
+}
+
+float G_Smith(float NdotV, float NdotL, float rough) {
+  float r = rough + 1.0;
+  float k = (r * r) / 8.0;
+  float gV = NdotV / (NdotV * (1.0 - k) + k);
+  float gL = NdotL / (NdotL * (1.0 - k) + k);
+  return gV * gL;
+}
+
+// screen-space bump from a height field h(uv) in world XZ
+vec3 perturbNormalWS(vec3 N, vec3 worldPos, float h, float scale) {
+  vec3 dpdx = dFdx(worldPos);
+  vec3 dpdy = dFdy(worldPos);
+  vec3 T = normalize(dpdx);
+  vec3 B = normalize(cross(N, T));
+  float hx = dFdx(h);
+  float hy = dFdy(h);
+  vec3 Np = normalize(N - scale * (hx * B + hy * T));
+  return Np;
+}
+
+// hemisphere ambient (sky/ground)
+vec3 hemilight(vec3 N) {
+  vec3 sky = vec3(0.46, 0.70, 0.82);
+  vec3 ground = vec3(0.22, 0.18, 0.14);
+  float t = saturate(N.y * 0.5 + 0.5);
+  return mix(ground, sky, t) * 0.29;
+}
+
+// ---------------------
+// main
+// ---------------------
+void main() {
+  vec3 baseColor = u_color;
+  if (u_useTexture)
+    baseColor *= texture(u_texture, v_texCoord).rgb;
+
+  vec3 N = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 5.0;
+
+  float avg = (baseColor.r + baseColor.g + baseColor.b) * (1.0 / 3.0);
+  float hueSpan = max(max(baseColor.r, baseColor.g), baseColor.b) -
+                  min(min(baseColor.r, baseColor.g), baseColor.b);
+
+  bool isBrass = (baseColor.r > baseColor.g * 1.15 &&
+                  baseColor.r > baseColor.b * 1.20 && avg > 0.50);
+  bool isSteel = (avg > 0.60 && !isBrass);
+  bool isChain = (!isSteel && !isBrass && avg > 0.40 && avg <= 0.60);
+  bool isFabric = (!isSteel && !isBrass && !isChain && avg > 0.25);
+  bool isLeather = (!isSteel && !isBrass && !isChain && !isFabric);
+  bool isHorseHide = (avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
+
+  // lighting frame
+  vec3 L = normalize(vec3(1.0, 1.2, 1.0));
+  vec3 V = normalize(
+      vec3(0.0, 1.0, 0.5)); // stable view proxy (keeps interface unchanged)
+  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));
+
+  // wrap diffuse like original (softens lambert)
+  float wrapAmount = (avg > 0.50) ? 0.08 : 0.30;
+  float NdotL_wrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.12);
+
+  // base material params
+  float roughness = 0.5;
+  vec3 F0 = vec3(0.04); // dielectric default
+  float metalness = 0.0;
+  vec3 albedo = baseColor;
+
+  // micro details / masks (re-used)
+  float nSmall = fbm(uv * 6.0);
+  float nLarge = fbm(uv * 2.0);
+  float cavity = 1.0 - (nLarge * 0.25 + nSmall * 0.15);
+
+  // ---------------------
+  // MATERIAL BRANCHES
+  // ---------------------
+  vec3 col = vec3(0.0);
+  vec3 ambient = hemilight(N) * (0.85 + 0.15 * cavity);
+
+  if (isHorseHide) {
+    // subtle anisotropic sheen along body flow
+    vec3 up = vec3(0.0, 1.0, 0.0);
+    vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess
+    float flowNoise = fbm(uv * 10.0);
+    float aniso = pow(saturate(dot(normalize(reflect(-L, N)), T)), 14.0) *
+                  0.08 * (0.6 + 0.4 * flowNoise);
+
+    float hideTex = horseHidePattern(v_worldPos.xz);
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.07;
+
+    roughness = 0.58 - hideTex * 0.08;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    // slight bump from hair grain
+    float h = fbm(v_worldPos.xz * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // composition
+    albedo = albedo * (1.0 + hideTex * 0.20) * (0.98 + 0.02 * nSmall);
+    col += ambient * albedo;
+    // microfacet spec
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+    col += NdotL_wrap * (albedo * (1.0 - F) * 0.95) + spec * 0.8;
+    col += aniso + sheen;
+
+  } else if (isSteel) {
+    float brushed =
+        abs(sin(v_worldPos.y * 95.0)) * 0.02 + noise(uv * 35.0) * 0.015;
+    float dents = noise(uv * 8.0) * 0.03;
+    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
+
+    // bump from brushing
+    float h = fbm(vec2(v_worldPos.y * 25.0, v_worldPos.z * 6.0));
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // steel-like params
+    metalness = 1.0;
+    F0 = vec3(0.92);
+    roughness = 0.28 + brushed * 2.0 + dents * 0.6;
+    roughness = clamp(roughness, 0.15, 0.55);
+
+    // base tint & sky reflection lift
+    albedo = mix(vec3(0.60), baseColor, 0.25);
+    float skyRefl = (N.y * 0.5 + 0.5) * 0.10;
+
+    // microfacet spec only for metals
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0 * albedo); // slight tint
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.3; // metals rely more on spec
+    col += NdotL_wrap * spec * 1.5;
+    col += vec3(plates) + vec3(skyRefl) - vec3(dents * 0.25) + vec3(brushed);
+
+  } else if (isBrass) {
+    float brassNoise = noise(uv * 22.0) * 0.02;
+    float patina = fbm(uv * 4.0) * 0.12; // larger-scale patina
+
+    // bump from subtle hammering
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.30);
+
+    metalness = 1.0;
+    vec3 brassTint = vec3(0.94, 0.78, 0.45);
+    F0 = mix(brassTint, baseColor, 0.5);
+    roughness = clamp(0.32 + patina * 0.45, 0.18, 0.75);
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * spec * 1.35;
+    col += vec3(brassNoise) - vec3(patina * 0.35);
+
+  } else if (isChain) {
+    float rings = chainmailRings(v_worldPos.xz);
+    float ringHi = noise(uv * 30.0) * 0.10;
+
+    // small pitted bump
+    float h = fbm(uv * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.25);
+
+    metalness = 1.0;
+    F0 = vec3(0.86);
+    roughness = 0.35;
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * (spec * (1.2 + rings)) + vec3(ringHi);
+    // slight diffuse damping to keep chainmail darker in cavities
+    col *= (0.95 - 0.10 * (1.0 - cavity));
+
+  } else if (isFabric) {
+    float weaveX = sin(v_worldPos.x * 70.0);
+    float weaveZ = sin(v_worldPos.z * 70.0);
+    float weave = weaveX * weaveZ * 0.04;
+    float embroidery = fbm(uv * 6.0) * 0.08;
+
+    float h = fbm(uv * 22.0) * 0.7 + weave * 0.6;
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    roughness = 0.78;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 6.0) * 0.10;
+    albedo *= 1.0 + fbm(uv * 5.0) * 0.10 - 0.05;
+
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F) + spec * 0.3) +
+           vec3(weave + embroidery + sheen);
+
+  } else { // leather
+    float grain = fbm(uv * 10.0) * 0.15;
+    float wear = fbm(uv * 3.0) * 0.12;
+
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.28);
+
+    roughness = 0.58 - wear * 0.15;
+    F0 = vec3(0.038);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.06;
+
+    albedo *= (1.0 + grain - 0.06 + wear * 0.05);
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F)) + spec * 0.4 + vec3(sheen);
+  }
+
+  // Apply Carthage-specific tint - more teal/turquoise for visibility
+  col = mix(col, vec3(0.20, 0.55, 0.60),
+            saturate((baseColor.g + baseColor.b) * 0.5) * 0.20);
+  col = saturate(col);
+  FragColor = vec4(col, u_alpha);
+}

+ 33 - 0
assets/shaders/horse_archer_carthage.vert

@@ -0,0 +1,33 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec3 v_normal;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+out float
+    v_armorLayer; // Distinguish armor pieces for Carthaginian Numidian cavalry
+
+void main() {
+  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+
+  // Detect armor layer based on Y position for Carthaginian Numidian cavalry
+  // Upper body (helmet) = 0, Torso (light armor/cloak) = 1, Lower (bare
+  // legs/horse) = 2
+  if (v_worldPos.y > 1.5) {
+    v_armorLayer = 0.0; // Bronze cap/no helmet region
+  } else if (v_worldPos.y > 0.8) {
+    v_armorLayer = 1.0; // Light tunic/imported mail region
+  } else {
+    v_armorLayer = 2.0; // Bare legs/simple saddle blanket region
+  }
+
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 354 - 0
assets/shaders/horse_archer_kingdom_of_iron.frag

@@ -0,0 +1,354 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+in float v_armorLayer; // Armor layer from vertex shader
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+// ---------------------
+// utilities & noise
+// ---------------------
+const float PI = 3.14159265359;
+
+float saturate(float x) { return clamp(x, 0.0, 1.0); }
+vec3 saturate(vec3 x) { return clamp(x, 0.0, 1.0); }
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float fbm(vec2 p) {
+  float a = 0.5;
+  float f = 0.0;
+  for (int i = 0; i < 5; ++i) {
+    f += a * noise(p);
+    p *= 2.03;
+    a *= 0.5;
+  }
+  return f;
+}
+
+// anti-aliased step
+float aaStep(float edge, float x) {
+  float w = fwidth(x);
+  return smoothstep(edge - w, edge + w, x);
+}
+
+// ---------------------
+// patterns
+// ---------------------
+
+// plate seams + rivets (AA)
+float armorPlates(vec2 p, float y) {
+  float plateY = fract(y * 6.5);
+  float line = smoothstep(0.92, 0.98, plateY) - smoothstep(0.98, 1.0, plateY);
+  // anti-aliased line thickness
+  line = smoothstep(0.0, fwidth(plateY) * 2.0, line) * 0.12;
+
+  // rivets on top seams
+  float rivetX = fract(p.x * 18.0);
+  float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
+  rivet *= step(0.92, plateY);
+  return line + rivet * 0.25;
+}
+
+// linked ring suggestion (AA)
+float chainmailRings(vec2 p) {
+  vec2 uv = p * 35.0;
+
+  vec2 g0 = fract(uv) - 0.5;
+  float r0 = length(g0);
+  float fw0 = fwidth(r0) * 1.2;
+  float ring0 = smoothstep(0.30 + fw0, 0.30 - fw0, r0) -
+                smoothstep(0.20 + fw0, 0.20 - fw0, r0);
+
+  vec2 g1 = fract(uv + vec2(0.5, 0.0)) - 0.5;
+  float r1 = length(g1);
+  float fw1 = fwidth(r1) * 1.2;
+  float ring1 = smoothstep(0.30 + fw1, 0.30 - fw1, r1) -
+                smoothstep(0.20 + fw1, 0.20 - fw1, r1);
+
+  return (ring0 + ring1) * 0.15;
+}
+
+float horseHidePattern(vec2 p) {
+  float grain = fbm(p * 80.0) * 0.10;
+  float ripple = sin(p.x * 22.0) * cos(p.y * 28.0) * 0.035;
+  float mottling = smoothstep(0.55, 0.65, fbm(p * 6.0)) * 0.07;
+  return grain + ripple + mottling;
+}
+
+// ---------------------
+// microfacet shading
+// ---------------------
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+}
+
+float D_GGX(float NdotH, float rough) {
+  float a = max(0.001, rough);
+  float a2 = a * a;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(1e-6, (PI * d * d));
+}
+
+float G_Smith(float NdotV, float NdotL, float rough) {
+  float r = rough + 1.0;
+  float k = (r * r) / 8.0;
+  float gV = NdotV / (NdotV * (1.0 - k) + k);
+  float gL = NdotL / (NdotL * (1.0 - k) + k);
+  return gV * gL;
+}
+
+// screen-space bump from a height field h(uv) in world XZ
+vec3 perturbNormalWS(vec3 N, vec3 worldPos, float h, float scale) {
+  vec3 dpdx = dFdx(worldPos);
+  vec3 dpdy = dFdy(worldPos);
+  vec3 T = normalize(dpdx);
+  vec3 B = normalize(cross(N, T));
+  float hx = dFdx(h);
+  float hy = dFdy(h);
+  vec3 Np = normalize(N - scale * (hx * B + hy * T));
+  return Np;
+}
+
+// hemisphere ambient (sky/ground)
+vec3 hemilight(vec3 N) {
+  vec3 sky = vec3(0.54, 0.66, 0.88);
+  vec3 ground = vec3(0.19, 0.18, 0.20);
+  float t = saturate(N.y * 0.5 + 0.5);
+  return mix(ground, sky, t) * 0.30;
+}
+
+// ---------------------
+// main
+// ---------------------
+void main() {
+  vec3 baseColor = u_color;
+  if (u_useTexture)
+    baseColor *= texture(u_texture, v_texCoord).rgb;
+
+  vec3 N = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 5.0;
+
+  float avg = (baseColor.r + baseColor.g + baseColor.b) * (1.0 / 3.0);
+  float hueSpan = max(max(baseColor.r, baseColor.g), baseColor.b) -
+                  min(min(baseColor.r, baseColor.g), baseColor.b);
+
+  bool isBrass = (baseColor.r > baseColor.g * 1.15 &&
+                  baseColor.r > baseColor.b * 1.20 && avg > 0.50);
+  bool isSteel = (avg > 0.60 && !isBrass);
+  bool isChain = (!isSteel && !isBrass && avg > 0.40 && avg <= 0.60);
+  bool isFabric = (!isSteel && !isBrass && !isChain && avg > 0.25);
+  bool isLeather = (!isSteel && !isBrass && !isChain && !isFabric);
+  bool isHorseHide = (avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
+
+  // lighting frame
+  vec3 L = normalize(vec3(1.0, 1.2, 1.0));
+  vec3 V = normalize(
+      vec3(0.0, 1.0, 0.5)); // stable view proxy (keeps interface unchanged)
+  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));
+
+  // wrap diffuse like original (softens lambert)
+  float wrapAmount = (avg > 0.50) ? 0.08 : 0.30;
+  float NdotL_wrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.12);
+
+  // base material params
+  float roughness = 0.5;
+  vec3 F0 = vec3(0.04); // dielectric default
+  float metalness = 0.0;
+  vec3 albedo = baseColor;
+
+  // micro details / masks (re-used)
+  float nSmall = fbm(uv * 6.0);
+  float nLarge = fbm(uv * 2.0);
+  float cavity = 1.0 - (nLarge * 0.25 + nSmall * 0.15);
+
+  // ---------------------
+  // MATERIAL BRANCHES
+  // ---------------------
+  vec3 col = vec3(0.0);
+  vec3 ambient = hemilight(N) * (0.85 + 0.15 * cavity);
+
+  if (isHorseHide) {
+    // subtle anisotropic sheen along body flow
+    vec3 up = vec3(0.0, 1.0, 0.0);
+    vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess
+    float flowNoise = fbm(uv * 10.0);
+    float aniso = pow(saturate(dot(normalize(reflect(-L, N)), T)), 14.0) *
+                  0.08 * (0.6 + 0.4 * flowNoise);
+
+    float hideTex = horseHidePattern(v_worldPos.xz);
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.07;
+
+    roughness = 0.58 - hideTex * 0.08;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    // slight bump from hair grain
+    float h = fbm(v_worldPos.xz * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // composition
+    albedo = albedo * (1.0 + hideTex * 0.20) * (0.98 + 0.02 * nSmall);
+    col += ambient * albedo;
+    // microfacet spec
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+    col += NdotL_wrap * (albedo * (1.0 - F) * 0.95) + spec * 0.8;
+    col += aniso + sheen;
+
+  } else if (isSteel) {
+    float brushed =
+        abs(sin(v_worldPos.y * 95.0)) * 0.02 + noise(uv * 35.0) * 0.015;
+    float dents = noise(uv * 8.0) * 0.03;
+    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
+
+    // bump from brushing
+    float h = fbm(vec2(v_worldPos.y * 25.0, v_worldPos.z * 6.0));
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // steel-like params
+    metalness = 1.0;
+    F0 = vec3(0.92);
+    roughness = 0.28 + brushed * 2.0 + dents * 0.6;
+    roughness = clamp(roughness, 0.15, 0.55);
+
+    // base tint & sky reflection lift
+    albedo = mix(vec3(0.60), baseColor, 0.25);
+    float skyRefl = (N.y * 0.5 + 0.5) * 0.10;
+
+    // microfacet spec only for metals
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0 * albedo); // slight tint
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.3; // metals rely more on spec
+    col += NdotL_wrap * spec * 1.5;
+    col += vec3(plates) + vec3(skyRefl) - vec3(dents * 0.25) + vec3(brushed);
+
+  } else if (isBrass) {
+    float brassNoise = noise(uv * 22.0) * 0.02;
+    float patina = fbm(uv * 4.0) * 0.12; // larger-scale patina
+
+    // bump from subtle hammering
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.30);
+
+    metalness = 1.0;
+    vec3 brassTint = vec3(0.94, 0.78, 0.45);
+    F0 = mix(brassTint, baseColor, 0.5);
+    roughness = clamp(0.32 + patina * 0.45, 0.18, 0.75);
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * spec * 1.35;
+    col += vec3(brassNoise) - vec3(patina * 0.35);
+
+  } else if (isChain) {
+    float rings = chainmailRings(v_worldPos.xz);
+    float ringHi = noise(uv * 30.0) * 0.10;
+
+    // small pitted bump
+    float h = fbm(uv * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.25);
+
+    metalness = 1.0;
+    F0 = vec3(0.86);
+    roughness = 0.35;
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * (spec * (1.2 + rings)) + vec3(ringHi);
+    // slight diffuse damping to keep chainmail darker in cavities
+    col *= (0.95 - 0.10 * (1.0 - cavity));
+
+  } else if (isFabric) {
+    float weaveX = sin(v_worldPos.x * 70.0);
+    float weaveZ = sin(v_worldPos.z * 70.0);
+    float weave = weaveX * weaveZ * 0.04;
+    float embroidery = fbm(uv * 6.0) * 0.08;
+
+    float h = fbm(uv * 22.0) * 0.7 + weave * 0.6;
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    roughness = 0.78;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 6.0) * 0.10;
+    albedo *= 1.0 + fbm(uv * 5.0) * 0.10 - 0.05;
+
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F) + spec * 0.3) +
+           vec3(weave + embroidery + sheen);
+
+  } else { // leather
+    float grain = fbm(uv * 10.0) * 0.15;
+    float wear = fbm(uv * 3.0) * 0.12;
+
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.28);
+
+    roughness = 0.58 - wear * 0.15;
+    F0 = vec3(0.038);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.06;
+
+    albedo *= (1.0 + grain - 0.06 + wear * 0.05);
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F)) + spec * 0.4 + vec3(sheen);
+  }
+
+  // subtle kingdom highlight along crest
+  col = mix(col, vec3(0.58, 0.64, 0.82), pow(1.0 - abs(N.y), 2.0) * 0.10);
+  col = saturate(col);
+  FragColor = vec4(col, u_alpha);
+}

+ 32 - 0
assets/shaders/horse_archer_kingdom_of_iron.vert

@@ -0,0 +1,32 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec3 v_normal;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+out float v_armorLayer; // Distinguish armor pieces for Kingdom cavalry
+
+void main() {
+  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+
+  // Detect armor layer based on Y position for Kingdom mounted knight
+  // Upper body (great helm) = 0, Torso (full plate) = 1, Lower (horse barding)
+  // = 2
+  if (v_worldPos.y > 1.5) {
+    v_armorLayer = 0.0; // Kingdom great helm/aventail region
+  } else if (v_worldPos.y > 0.8) {
+    v_armorLayer = 1.0; // Full plate armor/coat of plates region
+  } else {
+    v_armorLayer = 2.0; // Leg armor/horse caparison region
+  }
+
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 353 - 0
assets/shaders/horse_archer_roman_republic.frag

@@ -0,0 +1,353 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+in float v_armorLayer; // Armor layer from vertex shader
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+// ---------------------
+// utilities & noise
+// ---------------------
+const float PI = 3.14159265359;
+
+float saturate(float x) { return clamp(x, 0.0, 1.0); }
+vec3 saturate(vec3 x) { return clamp(x, 0.0, 1.0); }
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float fbm(vec2 p) {
+  float a = 0.5;
+  float f = 0.0;
+  for (int i = 0; i < 5; ++i) {
+    f += a * noise(p);
+    p *= 2.03;
+    a *= 0.5;
+  }
+  return f;
+}
+
+// anti-aliased step
+float aaStep(float edge, float x) {
+  float w = fwidth(x);
+  return smoothstep(edge - w, edge + w, x);
+}
+
+// ---------------------
+// patterns
+// ---------------------
+
+// plate seams + rivets (AA)
+float armorPlates(vec2 p, float y) {
+  float plateY = fract(y * 6.5);
+  float line = smoothstep(0.92, 0.98, plateY) - smoothstep(0.98, 1.0, plateY);
+  // anti-aliased line thickness
+  line = smoothstep(0.0, fwidth(plateY) * 2.0, line) * 0.12;
+
+  // rivets on top seams
+  float rivetX = fract(p.x * 18.0);
+  float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
+  rivet *= step(0.92, plateY);
+  return line + rivet * 0.25;
+}
+
+// linked ring suggestion (AA)
+float chainmailRings(vec2 p) {
+  vec2 uv = p * 35.0;
+
+  vec2 g0 = fract(uv) - 0.5;
+  float r0 = length(g0);
+  float fw0 = fwidth(r0) * 1.2;
+  float ring0 = smoothstep(0.30 + fw0, 0.30 - fw0, r0) -
+                smoothstep(0.20 + fw0, 0.20 - fw0, r0);
+
+  vec2 g1 = fract(uv + vec2(0.5, 0.0)) - 0.5;
+  float r1 = length(g1);
+  float fw1 = fwidth(r1) * 1.2;
+  float ring1 = smoothstep(0.30 + fw1, 0.30 - fw1, r1) -
+                smoothstep(0.20 + fw1, 0.20 - fw1, r1);
+
+  return (ring0 + ring1) * 0.15;
+}
+
+float horseHidePattern(vec2 p) {
+  float grain = fbm(p * 80.0) * 0.10;
+  float ripple = sin(p.x * 22.0) * cos(p.y * 28.0) * 0.035;
+  float mottling = smoothstep(0.55, 0.65, fbm(p * 6.0)) * 0.07;
+  return grain + ripple + mottling;
+}
+
+// ---------------------
+// microfacet shading
+// ---------------------
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+}
+
+float D_GGX(float NdotH, float rough) {
+  float a = max(0.001, rough);
+  float a2 = a * a;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(1e-6, (PI * d * d));
+}
+
+float G_Smith(float NdotV, float NdotL, float rough) {
+  float r = rough + 1.0;
+  float k = (r * r) / 8.0;
+  float gV = NdotV / (NdotV * (1.0 - k) + k);
+  float gL = NdotL / (NdotL * (1.0 - k) + k);
+  return gV * gL;
+}
+
+// screen-space bump from a height field h(uv) in world XZ
+vec3 perturbNormalWS(vec3 N, vec3 worldPos, float h, float scale) {
+  vec3 dpdx = dFdx(worldPos);
+  vec3 dpdy = dFdy(worldPos);
+  vec3 T = normalize(dpdx);
+  vec3 B = normalize(cross(N, T));
+  float hx = dFdx(h);
+  float hy = dFdy(h);
+  vec3 Np = normalize(N - scale * (hx * B + hy * T));
+  return Np;
+}
+
+// hemisphere ambient (sky/ground)
+vec3 hemilight(vec3 N) {
+  vec3 sky = vec3(0.55, 0.64, 0.80);
+  vec3 ground = vec3(0.23, 0.20, 0.17);
+  float t = saturate(N.y * 0.5 + 0.5);
+  return mix(ground, sky, t) * 0.28;
+}
+
+// ---------------------
+// main
+// ---------------------
+void main() {
+  vec3 baseColor = u_color;
+  if (u_useTexture)
+    baseColor *= texture(u_texture, v_texCoord).rgb;
+
+  vec3 N = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 5.0;
+
+  float avg = (baseColor.r + baseColor.g + baseColor.b) * (1.0 / 3.0);
+  float hueSpan = max(max(baseColor.r, baseColor.g), baseColor.b) -
+                  min(min(baseColor.r, baseColor.g), baseColor.b);
+
+  bool isBrass = (baseColor.r > baseColor.g * 1.15 &&
+                  baseColor.r > baseColor.b * 1.20 && avg > 0.50);
+  bool isSteel = (avg > 0.60 && !isBrass);
+  bool isChain = (!isSteel && !isBrass && avg > 0.40 && avg <= 0.60);
+  bool isFabric = (!isSteel && !isBrass && !isChain && avg > 0.25);
+  bool isLeather = (!isSteel && !isBrass && !isChain && !isFabric);
+  bool isHorseHide = (avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
+
+  // lighting frame
+  vec3 L = normalize(vec3(1.0, 1.2, 1.0));
+  vec3 V = normalize(
+      vec3(0.0, 1.0, 0.5)); // stable view proxy (keeps interface unchanged)
+  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));
+
+  // wrap diffuse like original (softens lambert)
+  float wrapAmount = (avg > 0.50) ? 0.08 : 0.30;
+  float NdotL_wrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.12);
+
+  // base material params
+  float roughness = 0.5;
+  vec3 F0 = vec3(0.04); // dielectric default
+  float metalness = 0.0;
+  vec3 albedo = baseColor;
+
+  // micro details / masks (re-used)
+  float nSmall = fbm(uv * 6.0);
+  float nLarge = fbm(uv * 2.0);
+  float cavity = 1.0 - (nLarge * 0.25 + nSmall * 0.15);
+
+  // ---------------------
+  // MATERIAL BRANCHES
+  // ---------------------
+  vec3 col = vec3(0.0);
+  vec3 ambient = hemilight(N) * (0.85 + 0.15 * cavity);
+
+  if (isHorseHide) {
+    // subtle anisotropic sheen along body flow
+    vec3 up = vec3(0.0, 1.0, 0.0);
+    vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess
+    float flowNoise = fbm(uv * 10.0);
+    float aniso = pow(saturate(dot(normalize(reflect(-L, N)), T)), 14.0) *
+                  0.08 * (0.6 + 0.4 * flowNoise);
+
+    float hideTex = horseHidePattern(v_worldPos.xz);
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.07;
+
+    roughness = 0.58 - hideTex * 0.08;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    // slight bump from hair grain
+    float h = fbm(v_worldPos.xz * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // composition
+    albedo = albedo * (1.0 + hideTex * 0.20) * (0.98 + 0.02 * nSmall);
+    col += ambient * albedo;
+    // microfacet spec
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+    col += NdotL_wrap * (albedo * (1.0 - F) * 0.95) + spec * 0.8;
+    col += aniso + sheen;
+
+  } else if (isSteel) {
+    float brushed =
+        abs(sin(v_worldPos.y * 95.0)) * 0.02 + noise(uv * 35.0) * 0.015;
+    float dents = noise(uv * 8.0) * 0.03;
+    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
+
+    // bump from brushing
+    float h = fbm(vec2(v_worldPos.y * 25.0, v_worldPos.z * 6.0));
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // steel-like params
+    metalness = 1.0;
+    F0 = vec3(0.92);
+    roughness = 0.28 + brushed * 2.0 + dents * 0.6;
+    roughness = clamp(roughness, 0.15, 0.55);
+
+    // base tint & sky reflection lift
+    albedo = mix(vec3(0.60), baseColor, 0.25);
+    float skyRefl = (N.y * 0.5 + 0.5) * 0.10;
+
+    // microfacet spec only for metals
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0 * albedo); // slight tint
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.3; // metals rely more on spec
+    col += NdotL_wrap * spec * 1.5;
+    col += vec3(plates) + vec3(skyRefl) - vec3(dents * 0.25) + vec3(brushed);
+
+  } else if (isBrass) {
+    float brassNoise = noise(uv * 22.0) * 0.02;
+    float patina = fbm(uv * 4.0) * 0.12; // larger-scale patina
+
+    // bump from subtle hammering
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.30);
+
+    metalness = 1.0;
+    vec3 brassTint = vec3(0.94, 0.78, 0.45);
+    F0 = mix(brassTint, baseColor, 0.5);
+    roughness = clamp(0.32 + patina * 0.45, 0.18, 0.75);
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * spec * 1.35;
+    col += vec3(brassNoise) - vec3(patina * 0.35);
+
+  } else if (isChain) {
+    float rings = chainmailRings(v_worldPos.xz);
+    float ringHi = noise(uv * 30.0) * 0.10;
+
+    // small pitted bump
+    float h = fbm(uv * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.25);
+
+    metalness = 1.0;
+    F0 = vec3(0.86);
+    roughness = 0.35;
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * (spec * (1.2 + rings)) + vec3(ringHi);
+    // slight diffuse damping to keep chainmail darker in cavities
+    col *= (0.95 - 0.10 * (1.0 - cavity));
+
+  } else if (isFabric) {
+    float weaveX = sin(v_worldPos.x * 70.0);
+    float weaveZ = sin(v_worldPos.z * 70.0);
+    float weave = weaveX * weaveZ * 0.04;
+    float embroidery = fbm(uv * 6.0) * 0.08;
+
+    float h = fbm(uv * 22.0) * 0.7 + weave * 0.6;
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    roughness = 0.78;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 6.0) * 0.10;
+    albedo *= 1.0 + fbm(uv * 5.0) * 0.10 - 0.05;
+
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F) + spec * 0.3) +
+           vec3(weave + embroidery + sheen);
+
+  } else { // leather
+    float grain = fbm(uv * 10.0) * 0.15;
+    float wear = fbm(uv * 3.0) * 0.12;
+
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.28);
+
+    roughness = 0.58 - wear * 0.15;
+    F0 = vec3(0.038);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.06;
+
+    albedo *= (1.0 + grain - 0.06 + wear * 0.05);
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F)) + spec * 0.4 + vec3(sheen);
+  }
+
+  // final clamp and alpha
+  col = saturate(col);
+  FragColor = vec4(col, u_alpha);
+}

+ 32 - 0
assets/shaders/horse_archer_roman_republic.vert

@@ -0,0 +1,32 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec3 v_normal;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+out float v_armorLayer; // Distinguish armor pieces for Roman equites cavalry
+
+void main() {
+  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+
+  // Detect armor layer based on Y position for Roman equestrian cavalry
+  // Upper body (attic helmet) = 0, Torso (muscle cuirass) = 1, Lower
+  // (pteruges/horse) = 2
+  if (v_worldPos.y > 1.5) {
+    v_armorLayer = 0.0; // Attic/phrygian helmet region
+  } else if (v_worldPos.y > 0.8) {
+    v_armorLayer = 1.0; // Bronze muscle cuirass/lorica squamata region
+  } else {
+    v_armorLayer = 2.0; // Riding pteruges/horse blanket region
+  }
+
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 355 - 0
assets/shaders/horse_spearman_carthage.frag

@@ -0,0 +1,355 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+in float v_armorLayer; // Armor layer from vertex shader
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+// ---------------------
+// utilities & noise
+// ---------------------
+const float PI = 3.14159265359;
+
+float saturate(float x) { return clamp(x, 0.0, 1.0); }
+vec3 saturate(vec3 x) { return clamp(x, 0.0, 1.0); }
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float fbm(vec2 p) {
+  float a = 0.5;
+  float f = 0.0;
+  for (int i = 0; i < 5; ++i) {
+    f += a * noise(p);
+    p *= 2.03;
+    a *= 0.5;
+  }
+  return f;
+}
+
+// anti-aliased step
+float aaStep(float edge, float x) {
+  float w = fwidth(x);
+  return smoothstep(edge - w, edge + w, x);
+}
+
+// ---------------------
+// patterns
+// ---------------------
+
+// plate seams + rivets (AA)
+float armorPlates(vec2 p, float y) {
+  float plateY = fract(y * 6.5);
+  float line = smoothstep(0.92, 0.98, plateY) - smoothstep(0.98, 1.0, plateY);
+  // anti-aliased line thickness
+  line = smoothstep(0.0, fwidth(plateY) * 2.0, line) * 0.12;
+
+  // rivets on top seams
+  float rivetX = fract(p.x * 18.0);
+  float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
+  rivet *= step(0.92, plateY);
+  return line + rivet * 0.25;
+}
+
+// linked ring suggestion (AA)
+float chainmailRings(vec2 p) {
+  vec2 uv = p * 35.0;
+
+  vec2 g0 = fract(uv) - 0.5;
+  float r0 = length(g0);
+  float fw0 = fwidth(r0) * 1.2;
+  float ring0 = smoothstep(0.30 + fw0, 0.30 - fw0, r0) -
+                smoothstep(0.20 + fw0, 0.20 - fw0, r0);
+
+  vec2 g1 = fract(uv + vec2(0.5, 0.0)) - 0.5;
+  float r1 = length(g1);
+  float fw1 = fwidth(r1) * 1.2;
+  float ring1 = smoothstep(0.30 + fw1, 0.30 - fw1, r1) -
+                smoothstep(0.20 + fw1, 0.20 - fw1, r1);
+
+  return (ring0 + ring1) * 0.15;
+}
+
+float horseHidePattern(vec2 p) {
+  float grain = fbm(p * 80.0) * 0.10;
+  float ripple = sin(p.x * 22.0) * cos(p.y * 28.0) * 0.035;
+  float mottling = smoothstep(0.55, 0.65, fbm(p * 6.0)) * 0.07;
+  return grain + ripple + mottling;
+}
+
+// ---------------------
+// microfacet shading
+// ---------------------
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+}
+
+float D_GGX(float NdotH, float rough) {
+  float a = max(0.001, rough);
+  float a2 = a * a;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(1e-6, (PI * d * d));
+}
+
+float G_Smith(float NdotV, float NdotL, float rough) {
+  float r = rough + 1.0;
+  float k = (r * r) / 8.0;
+  float gV = NdotV / (NdotV * (1.0 - k) + k);
+  float gL = NdotL / (NdotL * (1.0 - k) + k);
+  return gV * gL;
+}
+
+// screen-space bump from a height field h(uv) in world XZ
+vec3 perturbNormalWS(vec3 N, vec3 worldPos, float h, float scale) {
+  vec3 dpdx = dFdx(worldPos);
+  vec3 dpdy = dFdy(worldPos);
+  vec3 T = normalize(dpdx);
+  vec3 B = normalize(cross(N, T));
+  float hx = dFdx(h);
+  float hy = dFdy(h);
+  vec3 Np = normalize(N - scale * (hx * B + hy * T));
+  return Np;
+}
+
+// hemisphere ambient (sky/ground)
+vec3 hemilight(vec3 N) {
+  vec3 sky = vec3(0.46, 0.70, 0.82);
+  vec3 ground = vec3(0.22, 0.18, 0.14);
+  float t = saturate(N.y * 0.5 + 0.5);
+  return mix(ground, sky, t) * 0.29;
+}
+
+// ---------------------
+// main
+// ---------------------
+void main() {
+  vec3 baseColor = u_color;
+  if (u_useTexture)
+    baseColor *= texture(u_texture, v_texCoord).rgb;
+
+  vec3 N = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 5.0;
+
+  float avg = (baseColor.r + baseColor.g + baseColor.b) * (1.0 / 3.0);
+  float hueSpan = max(max(baseColor.r, baseColor.g), baseColor.b) -
+                  min(min(baseColor.r, baseColor.g), baseColor.b);
+
+  bool isBrass = (baseColor.r > baseColor.g * 1.15 &&
+                  baseColor.r > baseColor.b * 1.20 && avg > 0.50);
+  bool isSteel = (avg > 0.60 && !isBrass);
+  bool isChain = (!isSteel && !isBrass && avg > 0.40 && avg <= 0.60);
+  bool isFabric = (!isSteel && !isBrass && !isChain && avg > 0.25);
+  bool isLeather = (!isSteel && !isBrass && !isChain && !isFabric);
+  bool isHorseHide = (avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
+
+  // lighting frame
+  vec3 L = normalize(vec3(1.0, 1.2, 1.0));
+  vec3 V = normalize(
+      vec3(0.0, 1.0, 0.5)); // stable view proxy (keeps interface unchanged)
+  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));
+
+  // wrap diffuse like original (softens lambert)
+  float wrapAmount = (avg > 0.50) ? 0.08 : 0.30;
+  float NdotL_wrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.12);
+
+  // base material params
+  float roughness = 0.5;
+  vec3 F0 = vec3(0.04); // dielectric default
+  float metalness = 0.0;
+  vec3 albedo = baseColor;
+
+  // micro details / masks (re-used)
+  float nSmall = fbm(uv * 6.0);
+  float nLarge = fbm(uv * 2.0);
+  float cavity = 1.0 - (nLarge * 0.25 + nSmall * 0.15);
+
+  // ---------------------
+  // MATERIAL BRANCHES
+  // ---------------------
+  vec3 col = vec3(0.0);
+  vec3 ambient = hemilight(N) * (0.85 + 0.15 * cavity);
+
+  if (isHorseHide) {
+    // subtle anisotropic sheen along body flow
+    vec3 up = vec3(0.0, 1.0, 0.0);
+    vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess
+    float flowNoise = fbm(uv * 10.0);
+    float aniso = pow(saturate(dot(normalize(reflect(-L, N)), T)), 14.0) *
+                  0.08 * (0.6 + 0.4 * flowNoise);
+
+    float hideTex = horseHidePattern(v_worldPos.xz);
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.07;
+
+    roughness = 0.58 - hideTex * 0.08;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    // slight bump from hair grain
+    float h = fbm(v_worldPos.xz * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // composition
+    albedo = albedo * (1.0 + hideTex * 0.20) * (0.98 + 0.02 * nSmall);
+    col += ambient * albedo;
+    // microfacet spec
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+    col += NdotL_wrap * (albedo * (1.0 - F) * 0.95) + spec * 0.8;
+    col += aniso + sheen;
+
+  } else if (isSteel) {
+    float brushed =
+        abs(sin(v_worldPos.y * 95.0)) * 0.02 + noise(uv * 35.0) * 0.015;
+    float dents = noise(uv * 8.0) * 0.03;
+    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
+
+    // bump from brushing
+    float h = fbm(vec2(v_worldPos.y * 25.0, v_worldPos.z * 6.0));
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // steel-like params
+    metalness = 1.0;
+    F0 = vec3(0.92);
+    roughness = 0.28 + brushed * 2.0 + dents * 0.6;
+    roughness = clamp(roughness, 0.15, 0.55);
+
+    // base tint & sky reflection lift
+    albedo = mix(vec3(0.60), baseColor, 0.25);
+    float skyRefl = (N.y * 0.5 + 0.5) * 0.10;
+
+    // microfacet spec only for metals
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0 * albedo); // slight tint
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.3; // metals rely more on spec
+    col += NdotL_wrap * spec * 1.5;
+    col += vec3(plates) + vec3(skyRefl) - vec3(dents * 0.25) + vec3(brushed);
+
+  } else if (isBrass) {
+    float brassNoise = noise(uv * 22.0) * 0.02;
+    float patina = fbm(uv * 4.0) * 0.12; // larger-scale patina
+
+    // bump from subtle hammering
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.30);
+
+    metalness = 1.0;
+    vec3 brassTint = vec3(0.94, 0.78, 0.45);
+    F0 = mix(brassTint, baseColor, 0.5);
+    roughness = clamp(0.32 + patina * 0.45, 0.18, 0.75);
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * spec * 1.35;
+    col += vec3(brassNoise) - vec3(patina * 0.35);
+
+  } else if (isChain) {
+    float rings = chainmailRings(v_worldPos.xz);
+    float ringHi = noise(uv * 30.0) * 0.10;
+
+    // small pitted bump
+    float h = fbm(uv * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.25);
+
+    metalness = 1.0;
+    F0 = vec3(0.86);
+    roughness = 0.35;
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * (spec * (1.2 + rings)) + vec3(ringHi);
+    // slight diffuse damping to keep chainmail darker in cavities
+    col *= (0.95 - 0.10 * (1.0 - cavity));
+
+  } else if (isFabric) {
+    float weaveX = sin(v_worldPos.x * 70.0);
+    float weaveZ = sin(v_worldPos.z * 70.0);
+    float weave = weaveX * weaveZ * 0.04;
+    float embroidery = fbm(uv * 6.0) * 0.08;
+
+    float h = fbm(uv * 22.0) * 0.7 + weave * 0.6;
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    roughness = 0.78;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 6.0) * 0.10;
+    albedo *= 1.0 + fbm(uv * 5.0) * 0.10 - 0.05;
+
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F) + spec * 0.3) +
+           vec3(weave + embroidery + sheen);
+
+  } else { // leather
+    float grain = fbm(uv * 10.0) * 0.15;
+    float wear = fbm(uv * 3.0) * 0.12;
+
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.28);
+
+    roughness = 0.58 - wear * 0.15;
+    F0 = vec3(0.038);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.06;
+
+    albedo *= (1.0 + grain - 0.06 + wear * 0.05);
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F)) + spec * 0.4 + vec3(sheen);
+  }
+
+  // Apply Carthage-specific tint - more teal/turquoise for visibility
+  col = mix(col, vec3(0.20, 0.55, 0.60),
+            saturate((baseColor.g + baseColor.b) * 0.5) * 0.20);
+  col = saturate(col);
+  FragColor = vec4(col, u_alpha);
+}

+ 33 - 0
assets/shaders/horse_spearman_carthage.vert

@@ -0,0 +1,33 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec3 v_normal;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+out float
+    v_armorLayer; // Distinguish armor pieces for Carthaginian Numidian cavalry
+
+void main() {
+  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+
+  // Detect armor layer based on Y position for Carthaginian Numidian cavalry
+  // Upper body (helmet) = 0, Torso (light armor/cloak) = 1, Lower (bare
+  // legs/horse) = 2
+  if (v_worldPos.y > 1.5) {
+    v_armorLayer = 0.0; // Bronze cap/no helmet region
+  } else if (v_worldPos.y > 0.8) {
+    v_armorLayer = 1.0; // Light tunic/imported mail region
+  } else {
+    v_armorLayer = 2.0; // Bare legs/simple saddle blanket region
+  }
+
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 354 - 0
assets/shaders/horse_spearman_kingdom_of_iron.frag

@@ -0,0 +1,354 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+in float v_armorLayer; // Armor layer from vertex shader
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+// ---------------------
+// utilities & noise
+// ---------------------
+const float PI = 3.14159265359;
+
+float saturate(float x) { return clamp(x, 0.0, 1.0); }
+vec3 saturate(vec3 x) { return clamp(x, 0.0, 1.0); }
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float fbm(vec2 p) {
+  float a = 0.5;
+  float f = 0.0;
+  for (int i = 0; i < 5; ++i) {
+    f += a * noise(p);
+    p *= 2.03;
+    a *= 0.5;
+  }
+  return f;
+}
+
+// anti-aliased step
+float aaStep(float edge, float x) {
+  float w = fwidth(x);
+  return smoothstep(edge - w, edge + w, x);
+}
+
+// ---------------------
+// patterns
+// ---------------------
+
+// plate seams + rivets (AA)
+float armorPlates(vec2 p, float y) {
+  float plateY = fract(y * 6.5);
+  float line = smoothstep(0.92, 0.98, plateY) - smoothstep(0.98, 1.0, plateY);
+  // anti-aliased line thickness
+  line = smoothstep(0.0, fwidth(plateY) * 2.0, line) * 0.12;
+
+  // rivets on top seams
+  float rivetX = fract(p.x * 18.0);
+  float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
+  rivet *= step(0.92, plateY);
+  return line + rivet * 0.25;
+}
+
+// linked ring suggestion (AA)
+float chainmailRings(vec2 p) {
+  vec2 uv = p * 35.0;
+
+  vec2 g0 = fract(uv) - 0.5;
+  float r0 = length(g0);
+  float fw0 = fwidth(r0) * 1.2;
+  float ring0 = smoothstep(0.30 + fw0, 0.30 - fw0, r0) -
+                smoothstep(0.20 + fw0, 0.20 - fw0, r0);
+
+  vec2 g1 = fract(uv + vec2(0.5, 0.0)) - 0.5;
+  float r1 = length(g1);
+  float fw1 = fwidth(r1) * 1.2;
+  float ring1 = smoothstep(0.30 + fw1, 0.30 - fw1, r1) -
+                smoothstep(0.20 + fw1, 0.20 - fw1, r1);
+
+  return (ring0 + ring1) * 0.15;
+}
+
+float horseHidePattern(vec2 p) {
+  float grain = fbm(p * 80.0) * 0.10;
+  float ripple = sin(p.x * 22.0) * cos(p.y * 28.0) * 0.035;
+  float mottling = smoothstep(0.55, 0.65, fbm(p * 6.0)) * 0.07;
+  return grain + ripple + mottling;
+}
+
+// ---------------------
+// microfacet shading
+// ---------------------
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+}
+
+float D_GGX(float NdotH, float rough) {
+  float a = max(0.001, rough);
+  float a2 = a * a;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(1e-6, (PI * d * d));
+}
+
+float G_Smith(float NdotV, float NdotL, float rough) {
+  float r = rough + 1.0;
+  float k = (r * r) / 8.0;
+  float gV = NdotV / (NdotV * (1.0 - k) + k);
+  float gL = NdotL / (NdotL * (1.0 - k) + k);
+  return gV * gL;
+}
+
+// screen-space bump from a height field h(uv) in world XZ
+vec3 perturbNormalWS(vec3 N, vec3 worldPos, float h, float scale) {
+  vec3 dpdx = dFdx(worldPos);
+  vec3 dpdy = dFdy(worldPos);
+  vec3 T = normalize(dpdx);
+  vec3 B = normalize(cross(N, T));
+  float hx = dFdx(h);
+  float hy = dFdy(h);
+  vec3 Np = normalize(N - scale * (hx * B + hy * T));
+  return Np;
+}
+
+// hemisphere ambient (sky/ground)
+vec3 hemilight(vec3 N) {
+  vec3 sky = vec3(0.54, 0.66, 0.88);
+  vec3 ground = vec3(0.19, 0.18, 0.20);
+  float t = saturate(N.y * 0.5 + 0.5);
+  return mix(ground, sky, t) * 0.30;
+}
+
+// ---------------------
+// main
+// ---------------------
+void main() {
+  vec3 baseColor = u_color;
+  if (u_useTexture)
+    baseColor *= texture(u_texture, v_texCoord).rgb;
+
+  vec3 N = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 5.0;
+
+  float avg = (baseColor.r + baseColor.g + baseColor.b) * (1.0 / 3.0);
+  float hueSpan = max(max(baseColor.r, baseColor.g), baseColor.b) -
+                  min(min(baseColor.r, baseColor.g), baseColor.b);
+
+  bool isBrass = (baseColor.r > baseColor.g * 1.15 &&
+                  baseColor.r > baseColor.b * 1.20 && avg > 0.50);
+  bool isSteel = (avg > 0.60 && !isBrass);
+  bool isChain = (!isSteel && !isBrass && avg > 0.40 && avg <= 0.60);
+  bool isFabric = (!isSteel && !isBrass && !isChain && avg > 0.25);
+  bool isLeather = (!isSteel && !isBrass && !isChain && !isFabric);
+  bool isHorseHide = (avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
+
+  // lighting frame
+  vec3 L = normalize(vec3(1.0, 1.2, 1.0));
+  vec3 V = normalize(
+      vec3(0.0, 1.0, 0.5)); // stable view proxy (keeps interface unchanged)
+  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));
+
+  // wrap diffuse like original (softens lambert)
+  float wrapAmount = (avg > 0.50) ? 0.08 : 0.30;
+  float NdotL_wrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.12);
+
+  // base material params
+  float roughness = 0.5;
+  vec3 F0 = vec3(0.04); // dielectric default
+  float metalness = 0.0;
+  vec3 albedo = baseColor;
+
+  // micro details / masks (re-used)
+  float nSmall = fbm(uv * 6.0);
+  float nLarge = fbm(uv * 2.0);
+  float cavity = 1.0 - (nLarge * 0.25 + nSmall * 0.15);
+
+  // ---------------------
+  // MATERIAL BRANCHES
+  // ---------------------
+  vec3 col = vec3(0.0);
+  vec3 ambient = hemilight(N) * (0.85 + 0.15 * cavity);
+
+  if (isHorseHide) {
+    // subtle anisotropic sheen along body flow
+    vec3 up = vec3(0.0, 1.0, 0.0);
+    vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess
+    float flowNoise = fbm(uv * 10.0);
+    float aniso = pow(saturate(dot(normalize(reflect(-L, N)), T)), 14.0) *
+                  0.08 * (0.6 + 0.4 * flowNoise);
+
+    float hideTex = horseHidePattern(v_worldPos.xz);
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.07;
+
+    roughness = 0.58 - hideTex * 0.08;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    // slight bump from hair grain
+    float h = fbm(v_worldPos.xz * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // composition
+    albedo = albedo * (1.0 + hideTex * 0.20) * (0.98 + 0.02 * nSmall);
+    col += ambient * albedo;
+    // microfacet spec
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+    col += NdotL_wrap * (albedo * (1.0 - F) * 0.95) + spec * 0.8;
+    col += aniso + sheen;
+
+  } else if (isSteel) {
+    float brushed =
+        abs(sin(v_worldPos.y * 95.0)) * 0.02 + noise(uv * 35.0) * 0.015;
+    float dents = noise(uv * 8.0) * 0.03;
+    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
+
+    // bump from brushing
+    float h = fbm(vec2(v_worldPos.y * 25.0, v_worldPos.z * 6.0));
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // steel-like params
+    metalness = 1.0;
+    F0 = vec3(0.92);
+    roughness = 0.28 + brushed * 2.0 + dents * 0.6;
+    roughness = clamp(roughness, 0.15, 0.55);
+
+    // base tint & sky reflection lift
+    albedo = mix(vec3(0.60), baseColor, 0.25);
+    float skyRefl = (N.y * 0.5 + 0.5) * 0.10;
+
+    // microfacet spec only for metals
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0 * albedo); // slight tint
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.3; // metals rely more on spec
+    col += NdotL_wrap * spec * 1.5;
+    col += vec3(plates) + vec3(skyRefl) - vec3(dents * 0.25) + vec3(brushed);
+
+  } else if (isBrass) {
+    float brassNoise = noise(uv * 22.0) * 0.02;
+    float patina = fbm(uv * 4.0) * 0.12; // larger-scale patina
+
+    // bump from subtle hammering
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.30);
+
+    metalness = 1.0;
+    vec3 brassTint = vec3(0.94, 0.78, 0.45);
+    F0 = mix(brassTint, baseColor, 0.5);
+    roughness = clamp(0.32 + patina * 0.45, 0.18, 0.75);
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * spec * 1.35;
+    col += vec3(brassNoise) - vec3(patina * 0.35);
+
+  } else if (isChain) {
+    float rings = chainmailRings(v_worldPos.xz);
+    float ringHi = noise(uv * 30.0) * 0.10;
+
+    // small pitted bump
+    float h = fbm(uv * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.25);
+
+    metalness = 1.0;
+    F0 = vec3(0.86);
+    roughness = 0.35;
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * (spec * (1.2 + rings)) + vec3(ringHi);
+    // slight diffuse damping to keep chainmail darker in cavities
+    col *= (0.95 - 0.10 * (1.0 - cavity));
+
+  } else if (isFabric) {
+    float weaveX = sin(v_worldPos.x * 70.0);
+    float weaveZ = sin(v_worldPos.z * 70.0);
+    float weave = weaveX * weaveZ * 0.04;
+    float embroidery = fbm(uv * 6.0) * 0.08;
+
+    float h = fbm(uv * 22.0) * 0.7 + weave * 0.6;
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    roughness = 0.78;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 6.0) * 0.10;
+    albedo *= 1.0 + fbm(uv * 5.0) * 0.10 - 0.05;
+
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F) + spec * 0.3) +
+           vec3(weave + embroidery + sheen);
+
+  } else { // leather
+    float grain = fbm(uv * 10.0) * 0.15;
+    float wear = fbm(uv * 3.0) * 0.12;
+
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.28);
+
+    roughness = 0.58 - wear * 0.15;
+    F0 = vec3(0.038);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.06;
+
+    albedo *= (1.0 + grain - 0.06 + wear * 0.05);
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F)) + spec * 0.4 + vec3(sheen);
+  }
+
+  // subtle kingdom highlight along crest
+  col = mix(col, vec3(0.58, 0.64, 0.82), pow(1.0 - abs(N.y), 2.0) * 0.10);
+  col = saturate(col);
+  FragColor = vec4(col, u_alpha);
+}

+ 32 - 0
assets/shaders/horse_spearman_kingdom_of_iron.vert

@@ -0,0 +1,32 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec3 v_normal;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+out float v_armorLayer; // Distinguish armor pieces for Kingdom cavalry
+
+void main() {
+  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+
+  // Detect armor layer based on Y position for Kingdom mounted knight
+  // Upper body (great helm) = 0, Torso (full plate) = 1, Lower (horse barding)
+  // = 2
+  if (v_worldPos.y > 1.5) {
+    v_armorLayer = 0.0; // Kingdom great helm/aventail region
+  } else if (v_worldPos.y > 0.8) {
+    v_armorLayer = 1.0; // Full plate armor/coat of plates region
+  } else {
+    v_armorLayer = 2.0; // Leg armor/horse caparison region
+  }
+
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 353 - 0
assets/shaders/horse_spearman_roman_republic.frag

@@ -0,0 +1,353 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+in float v_armorLayer; // Armor layer from vertex shader
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+// ---------------------
+// utilities & noise
+// ---------------------
+const float PI = 3.14159265359;
+
+float saturate(float x) { return clamp(x, 0.0, 1.0); }
+vec3 saturate(vec3 x) { return clamp(x, 0.0, 1.0); }
+
+float hash(vec2 p) {
+  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
+  p3 += dot(p3, p3.yzx + 33.33);
+  return fract((p3.x + p3.y) * p3.z);
+}
+
+float noise(vec2 p) {
+  vec2 i = floor(p);
+  vec2 f = fract(p);
+  f = f * f * (3.0 - 2.0 * f);
+  float a = hash(i);
+  float b = hash(i + vec2(1.0, 0.0));
+  float c = hash(i + vec2(0.0, 1.0));
+  float d = hash(i + vec2(1.0, 1.0));
+  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
+}
+
+float fbm(vec2 p) {
+  float a = 0.5;
+  float f = 0.0;
+  for (int i = 0; i < 5; ++i) {
+    f += a * noise(p);
+    p *= 2.03;
+    a *= 0.5;
+  }
+  return f;
+}
+
+// anti-aliased step
+float aaStep(float edge, float x) {
+  float w = fwidth(x);
+  return smoothstep(edge - w, edge + w, x);
+}
+
+// ---------------------
+// patterns
+// ---------------------
+
+// plate seams + rivets (AA)
+float armorPlates(vec2 p, float y) {
+  float plateY = fract(y * 6.5);
+  float line = smoothstep(0.92, 0.98, plateY) - smoothstep(0.98, 1.0, plateY);
+  // anti-aliased line thickness
+  line = smoothstep(0.0, fwidth(plateY) * 2.0, line) * 0.12;
+
+  // rivets on top seams
+  float rivetX = fract(p.x * 18.0);
+  float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
+  rivet *= step(0.92, plateY);
+  return line + rivet * 0.25;
+}
+
+// linked ring suggestion (AA)
+float chainmailRings(vec2 p) {
+  vec2 uv = p * 35.0;
+
+  vec2 g0 = fract(uv) - 0.5;
+  float r0 = length(g0);
+  float fw0 = fwidth(r0) * 1.2;
+  float ring0 = smoothstep(0.30 + fw0, 0.30 - fw0, r0) -
+                smoothstep(0.20 + fw0, 0.20 - fw0, r0);
+
+  vec2 g1 = fract(uv + vec2(0.5, 0.0)) - 0.5;
+  float r1 = length(g1);
+  float fw1 = fwidth(r1) * 1.2;
+  float ring1 = smoothstep(0.30 + fw1, 0.30 - fw1, r1) -
+                smoothstep(0.20 + fw1, 0.20 - fw1, r1);
+
+  return (ring0 + ring1) * 0.15;
+}
+
+float horseHidePattern(vec2 p) {
+  float grain = fbm(p * 80.0) * 0.10;
+  float ripple = sin(p.x * 22.0) * cos(p.y * 28.0) * 0.035;
+  float mottling = smoothstep(0.55, 0.65, fbm(p * 6.0)) * 0.07;
+  return grain + ripple + mottling;
+}
+
+// ---------------------
+// microfacet shading
+// ---------------------
+vec3 fresnelSchlick(float cosTheta, vec3 F0) {
+  return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
+}
+
+float D_GGX(float NdotH, float rough) {
+  float a = max(0.001, rough);
+  float a2 = a * a;
+  float d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+  return a2 / max(1e-6, (PI * d * d));
+}
+
+float G_Smith(float NdotV, float NdotL, float rough) {
+  float r = rough + 1.0;
+  float k = (r * r) / 8.0;
+  float gV = NdotV / (NdotV * (1.0 - k) + k);
+  float gL = NdotL / (NdotL * (1.0 - k) + k);
+  return gV * gL;
+}
+
+// screen-space bump from a height field h(uv) in world XZ
+vec3 perturbNormalWS(vec3 N, vec3 worldPos, float h, float scale) {
+  vec3 dpdx = dFdx(worldPos);
+  vec3 dpdy = dFdy(worldPos);
+  vec3 T = normalize(dpdx);
+  vec3 B = normalize(cross(N, T));
+  float hx = dFdx(h);
+  float hy = dFdy(h);
+  vec3 Np = normalize(N - scale * (hx * B + hy * T));
+  return Np;
+}
+
+// hemisphere ambient (sky/ground)
+vec3 hemilight(vec3 N) {
+  vec3 sky = vec3(0.55, 0.64, 0.80);
+  vec3 ground = vec3(0.23, 0.20, 0.17);
+  float t = saturate(N.y * 0.5 + 0.5);
+  return mix(ground, sky, t) * 0.28;
+}
+
+// ---------------------
+// main
+// ---------------------
+void main() {
+  vec3 baseColor = u_color;
+  if (u_useTexture)
+    baseColor *= texture(u_texture, v_texCoord).rgb;
+
+  vec3 N = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 5.0;
+
+  float avg = (baseColor.r + baseColor.g + baseColor.b) * (1.0 / 3.0);
+  float hueSpan = max(max(baseColor.r, baseColor.g), baseColor.b) -
+                  min(min(baseColor.r, baseColor.g), baseColor.b);
+
+  bool isBrass = (baseColor.r > baseColor.g * 1.15 &&
+                  baseColor.r > baseColor.b * 1.20 && avg > 0.50);
+  bool isSteel = (avg > 0.60 && !isBrass);
+  bool isChain = (!isSteel && !isBrass && avg > 0.40 && avg <= 0.60);
+  bool isFabric = (!isSteel && !isBrass && !isChain && avg > 0.25);
+  bool isLeather = (!isSteel && !isBrass && !isChain && !isFabric);
+  bool isHorseHide = (avg < 0.40 && hueSpan < 0.12 && v_worldPos.y < 0.8);
+
+  // lighting frame
+  vec3 L = normalize(vec3(1.0, 1.2, 1.0));
+  vec3 V = normalize(
+      vec3(0.0, 1.0, 0.5)); // stable view proxy (keeps interface unchanged)
+  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));
+
+  // wrap diffuse like original (softens lambert)
+  float wrapAmount = (avg > 0.50) ? 0.08 : 0.30;
+  float NdotL_wrap = max(NdotL * (1.0 - wrapAmount) + wrapAmount, 0.12);
+
+  // base material params
+  float roughness = 0.5;
+  vec3 F0 = vec3(0.04); // dielectric default
+  float metalness = 0.0;
+  vec3 albedo = baseColor;
+
+  // micro details / masks (re-used)
+  float nSmall = fbm(uv * 6.0);
+  float nLarge = fbm(uv * 2.0);
+  float cavity = 1.0 - (nLarge * 0.25 + nSmall * 0.15);
+
+  // ---------------------
+  // MATERIAL BRANCHES
+  // ---------------------
+  vec3 col = vec3(0.0);
+  vec3 ambient = hemilight(N) * (0.85 + 0.15 * cavity);
+
+  if (isHorseHide) {
+    // subtle anisotropic sheen along body flow
+    vec3 up = vec3(0.0, 1.0, 0.0);
+    vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess
+    float flowNoise = fbm(uv * 10.0);
+    float aniso = pow(saturate(dot(normalize(reflect(-L, N)), T)), 14.0) *
+                  0.08 * (0.6 + 0.4 * flowNoise);
+
+    float hideTex = horseHidePattern(v_worldPos.xz);
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.07;
+
+    roughness = 0.58 - hideTex * 0.08;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    // slight bump from hair grain
+    float h = fbm(v_worldPos.xz * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // composition
+    albedo = albedo * (1.0 + hideTex * 0.20) * (0.98 + 0.02 * nSmall);
+    col += ambient * albedo;
+    // microfacet spec
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+    col += NdotL_wrap * (albedo * (1.0 - F) * 0.95) + spec * 0.8;
+    col += aniso + sheen;
+
+  } else if (isSteel) {
+    float brushed =
+        abs(sin(v_worldPos.y * 95.0)) * 0.02 + noise(uv * 35.0) * 0.015;
+    float dents = noise(uv * 8.0) * 0.03;
+    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
+
+    // bump from brushing
+    float h = fbm(vec2(v_worldPos.y * 25.0, v_worldPos.z * 6.0));
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    // steel-like params
+    metalness = 1.0;
+    F0 = vec3(0.92);
+    roughness = 0.28 + brushed * 2.0 + dents * 0.6;
+    roughness = clamp(roughness, 0.15, 0.55);
+
+    // base tint & sky reflection lift
+    albedo = mix(vec3(0.60), baseColor, 0.25);
+    float skyRefl = (N.y * 0.5 + 0.5) * 0.10;
+
+    // microfacet spec only for metals
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0 * albedo); // slight tint
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.3; // metals rely more on spec
+    col += NdotL_wrap * spec * 1.5;
+    col += vec3(plates) + vec3(skyRefl) - vec3(dents * 0.25) + vec3(brushed);
+
+  } else if (isBrass) {
+    float brassNoise = noise(uv * 22.0) * 0.02;
+    float patina = fbm(uv * 4.0) * 0.12; // larger-scale patina
+
+    // bump from subtle hammering
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.30);
+
+    metalness = 1.0;
+    vec3 brassTint = vec3(0.94, 0.78, 0.45);
+    F0 = mix(brassTint, baseColor, 0.5);
+    roughness = clamp(0.32 + patina * 0.45, 0.18, 0.75);
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * spec * 1.35;
+    col += vec3(brassNoise) - vec3(patina * 0.35);
+
+  } else if (isChain) {
+    float rings = chainmailRings(v_worldPos.xz);
+    float ringHi = noise(uv * 30.0) * 0.10;
+
+    // small pitted bump
+    float h = fbm(uv * 35.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.25);
+
+    metalness = 1.0;
+    F0 = vec3(0.86);
+    roughness = 0.35;
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnelSchlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * 0.25;
+    col += NdotL_wrap * (spec * (1.2 + rings)) + vec3(ringHi);
+    // slight diffuse damping to keep chainmail darker in cavities
+    col *= (0.95 - 0.10 * (1.0 - cavity));
+
+  } else if (isFabric) {
+    float weaveX = sin(v_worldPos.x * 70.0);
+    float weaveZ = sin(v_worldPos.z * 70.0);
+    float weave = weaveX * weaveZ * 0.04;
+    float embroidery = fbm(uv * 6.0) * 0.08;
+
+    float h = fbm(uv * 22.0) * 0.7 + weave * 0.6;
+    N = perturbNormalWS(N, v_worldPos, h, 0.35);
+
+    roughness = 0.78;
+    F0 = vec3(0.035);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 6.0) * 0.10;
+    albedo *= 1.0 + fbm(uv * 5.0) * 0.10 - 0.05;
+
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F) + spec * 0.3) +
+           vec3(weave + embroidery + sheen);
+
+  } else { // leather
+    float grain = fbm(uv * 10.0) * 0.15;
+    float wear = fbm(uv * 3.0) * 0.12;
+
+    float h = fbm(uv * 18.0);
+    N = perturbNormalWS(N, v_worldPos, h, 0.28);
+
+    roughness = 0.58 - wear * 0.15;
+    F0 = vec3(0.038);
+    metalness = 0.0;
+
+    vec3 F = fresnelSchlick(VdotH, F0);
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    float sheen = pow(1.0 - NdotV, 4.0) * 0.06;
+
+    albedo *= (1.0 + grain - 0.06 + wear * 0.05);
+    col += ambient * albedo;
+    col += NdotL_wrap * (albedo * (1.0 - F)) + spec * 0.4 + vec3(sheen);
+  }
+
+  // final clamp and alpha
+  col = saturate(col);
+  FragColor = vec4(col, u_alpha);
+}

+ 32 - 0
assets/shaders/horse_spearman_roman_republic.vert

@@ -0,0 +1,32 @@
+#version 330 core
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec3 v_normal;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+out float v_armorLayer; // Distinguish armor pieces for Roman equites cavalry
+
+void main() {
+  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+
+  // Detect armor layer based on Y position for Roman equestrian cavalry
+  // Upper body (attic helmet) = 0, Torso (muscle cuirass) = 1, Lower
+  // (pteruges/horse) = 2
+  if (v_worldPos.y > 1.5) {
+    v_armorLayer = 0.0; // Attic/phrygian helmet region
+  } else if (v_worldPos.y > 0.8) {
+    v_armorLayer = 1.0; // Bronze muscle cuirass/lorica squamata region
+  } else {
+    v_armorLayer = 2.0; // Riding pteruges/horse blanket region
+  }
+
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

BIN
assets/visuals/icons/archer_cartaghe.png


BIN
assets/visuals/icons/archer_rome.png


BIN
assets/visuals/icons/ballista_cartaghe.png


BIN
assets/visuals/icons/ballista_rome.png


BIN
assets/visuals/icons/catapult_cartaghe.png


BIN
assets/visuals/icons/catapult_rome.png


BIN
assets/visuals/icons/healer_cartaghe.png


BIN
assets/visuals/icons/healer_rome.png


BIN
assets/visuals/icons/horse_archer_cartaghe.png


BIN
assets/visuals/icons/horse_archer_rome.png


BIN
assets/visuals/icons/horse_spearman_cartaghe.png


BIN
assets/visuals/icons/horse_spearman_rome.png


BIN
assets/visuals/icons/horse_swordsman_cartaghe.png


BIN
assets/visuals/icons/horse_swordsman_rome.png


BIN
assets/visuals/icons/spearman_cartaghe.png


BIN
assets/visuals/icons/spearman_rome.png


BIN
assets/visuals/icons/swordsman_cartaghe.png


BIN
assets/visuals/icons/swordsman_rome.png


+ 2 - 0
game/CMakeLists.txt

@@ -68,6 +68,8 @@ add_library(game_systems STATIC
     units/archer.cpp
     units/swordsman.cpp
     units/horse_swordsman.cpp
+    units/horse_archer.cpp
+    units/horse_spearman.cpp
     units/spearman.cpp
     units/troop_catalog.cpp
     units/troop_catalog_loader.cpp

+ 5 - 0
game/systems/production_service.cpp

@@ -140,6 +140,11 @@ auto ProductionService::getSelectedBarracksState(
     return false;
   }
   outState.has_barracks = true;
+  if (auto *unit = e->getComponent<Engine::Core::UnitComponent>()) {
+    outState.nation_id = resolve_nation_id(unit, owner_id);
+  } else {
+    outState.nation_id = NationRegistry::instance().default_nation_id();
+  }
   if (auto *p = e->getComponent<Engine::Core::ProductionComponent>()) {
     outState.inProgress = p->inProgress;
     outState.product_type = p->product_type;

+ 2 - 0
game/systems/production_service.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include "../units/troop_type.h"
+#include "nation_id.h"
 #include <string>
 #include <vector>
 
@@ -23,6 +24,7 @@ enum class ProductionResult {
 struct ProductionState {
   bool has_barracks = false;
   bool inProgress = false;
+  NationID nation_id = NationID::KingdomOfIron;
   Game::Units::TroopType product_type = Game::Units::TroopType::Archer;
   float timeRemaining = 0.0F;
   float buildTime = 0.0F;

+ 12 - 0
game/units/factory.cpp

@@ -1,6 +1,8 @@
 #include "factory.h"
 #include "archer.h"
 #include "barracks.h"
+#include "horse_archer.h"
+#include "horse_spearman.h"
 #include "horse_swordsman.h"
 #include "spearman.h"
 #include "swordsman.h"
@@ -30,6 +32,16 @@ void registerBuiltInUnits(UnitFactoryRegistry &reg) {
     return Spearman::Create(world, params);
   });
 
+  reg.registerFactory(SpawnType::HorseArcher, [](Engine::Core::World &world,
+                                                 const SpawnParams &params) {
+    return HorseArcher::Create(world, params);
+  });
+
+  reg.registerFactory(SpawnType::HorseSpearman, [](Engine::Core::World &world,
+                                                   const SpawnParams &params) {
+    return HorseSpearman::Create(world, params);
+  });
+
   reg.registerFactory(SpawnType::Barracks, [](Engine::Core::World &world,
                                               const SpawnParams &params) {
     return Barracks::Create(world, params);

+ 109 - 0
game/units/horse_archer.cpp

@@ -0,0 +1,109 @@
+#include "horse_archer.h"
+#include "../core/component.h"
+#include "../core/event_manager.h"
+#include "../core/world.h"
+#include "../systems/troop_profile_service.h"
+#include "units/troop_type.h"
+#include "units/unit.h"
+#include <memory>
+#include <qvectornd.h>
+
+static inline auto team_color(int owner_id) -> QVector3D {
+  switch (owner_id) {
+  case 1:
+    return {0.20F, 0.55F, 1.00F};
+  case 2:
+    return {1.00F, 0.30F, 0.30F};
+  case 3:
+    return {0.20F, 0.80F, 0.40F};
+  case 4:
+    return {1.00F, 0.80F, 0.20F};
+  default:
+    return {0.8F, 0.8F, 0.8F};
+  }
+}
+
+namespace Game::Units {
+
+HorseArcher::HorseArcher(Engine::Core::World &world)
+    : Unit(world, TroopType::HorseArcher) {}
+
+auto HorseArcher::Create(Engine::Core::World &world,
+                           const SpawnParams &params)
+    -> std::unique_ptr<HorseArcher> {
+  auto unit = std::unique_ptr<HorseArcher>(new HorseArcher(world));
+  unit->init(params);
+  return unit;
+}
+
+void HorseArcher::init(const SpawnParams &params) {
+
+  auto *e = m_world->createEntity();
+  m_id = e->getId();
+
+  const auto nation_id = resolve_nation_id(params);
+  auto profile = Game::Systems::TroopProfileService::instance().get_profile(
+      nation_id, TroopType::HorseArcher);
+
+  m_t = e->addComponent<Engine::Core::TransformComponent>();
+  m_t->position = {params.position.x(), params.position.y(),
+                   params.position.z()};
+  float const scale = profile.visuals.render_scale;
+  m_t->scale = {scale, scale, scale};
+
+  m_r = e->addComponent<Engine::Core::RenderableComponent>("", "");
+  m_r->visible = true;
+  m_r->rendererId = profile.visuals.renderer_id;
+
+  m_u = e->addComponent<Engine::Core::UnitComponent>();
+  m_u->spawn_type = params.spawn_type;
+  m_u->health = profile.combat.health;
+  m_u->max_health = profile.combat.max_health;
+  m_u->speed = profile.combat.speed;
+  m_u->owner_id = params.player_id;
+  m_u->vision_range = profile.combat.vision_range;
+  m_u->nation_id = nation_id;
+
+  if (params.aiControlled) {
+    e->addComponent<Engine::Core::AIControlledComponent>();
+  } else {
+  }
+
+  QVector3D const tc = team_color(m_u->owner_id);
+  m_r->color[0] = tc.x();
+  m_r->color[1] = tc.y();
+  m_r->color[2] = tc.z();
+
+  m_mv = e->addComponent<Engine::Core::MovementComponent>();
+  if (m_mv != nullptr) {
+    m_mv->goalX = params.position.x();
+    m_mv->goalY = params.position.z();
+    m_mv->target_x = params.position.x();
+    m_mv->target_y = params.position.z();
+  }
+
+  m_atk = e->addComponent<Engine::Core::AttackComponent>();
+
+  m_atk->range = profile.combat.ranged_range;
+  m_atk->damage = profile.combat.ranged_damage;
+  m_atk->cooldown = profile.combat.ranged_cooldown;
+
+  m_atk->meleeRange = profile.combat.melee_range;
+  m_atk->meleeDamage = profile.combat.melee_damage;
+  m_atk->meleeCooldown = profile.combat.melee_cooldown;
+
+  m_atk->preferredMode = profile.combat.can_ranged
+                             ? Engine::Core::AttackComponent::CombatMode::Auto
+                             : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->currentMode = profile.combat.can_ranged
+                           ? Engine::Core::AttackComponent::CombatMode::Ranged
+                           : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->canRanged = profile.combat.can_ranged;
+  m_atk->canMelee = profile.combat.can_melee;
+  m_atk->max_heightDifference = 2.0F;
+
+  Engine::Core::EventManager::instance().publish(
+      Engine::Core::UnitSpawnedEvent(m_id, m_u->owner_id, m_u->spawn_type));
+}
+
+} // namespace Game::Units

+ 17 - 0
game/units/horse_archer.h

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

+ 109 - 0
game/units/horse_spearman.cpp

@@ -0,0 +1,109 @@
+#include "horse_spearman.h"
+#include "../core/component.h"
+#include "../core/event_manager.h"
+#include "../core/world.h"
+#include "../systems/troop_profile_service.h"
+#include "units/troop_type.h"
+#include "units/unit.h"
+#include <memory>
+#include <qvectornd.h>
+
+static inline auto team_color(int owner_id) -> QVector3D {
+  switch (owner_id) {
+  case 1:
+    return {0.20F, 0.55F, 1.00F};
+  case 2:
+    return {1.00F, 0.30F, 0.30F};
+  case 3:
+    return {0.20F, 0.80F, 0.40F};
+  case 4:
+    return {1.00F, 0.80F, 0.20F};
+  default:
+    return {0.8F, 0.8F, 0.8F};
+  }
+}
+
+namespace Game::Units {
+
+HorseSpearman::HorseSpearman(Engine::Core::World &world)
+    : Unit(world, TroopType::HorseSpearman) {}
+
+auto HorseSpearman::Create(Engine::Core::World &world,
+                             const SpawnParams &params)
+    -> std::unique_ptr<HorseSpearman> {
+  auto unit = std::unique_ptr<HorseSpearman>(new HorseSpearman(world));
+  unit->init(params);
+  return unit;
+}
+
+void HorseSpearman::init(const SpawnParams &params) {
+
+  auto *e = m_world->createEntity();
+  m_id = e->getId();
+
+  const auto nation_id = resolve_nation_id(params);
+  auto profile = Game::Systems::TroopProfileService::instance().get_profile(
+      nation_id, TroopType::HorseSpearman);
+
+  m_t = e->addComponent<Engine::Core::TransformComponent>();
+  m_t->position = {params.position.x(), params.position.y(),
+                   params.position.z()};
+  float const scale = profile.visuals.render_scale;
+  m_t->scale = {scale, scale, scale};
+
+  m_r = e->addComponent<Engine::Core::RenderableComponent>("", "");
+  m_r->visible = true;
+  m_r->rendererId = profile.visuals.renderer_id;
+
+  m_u = e->addComponent<Engine::Core::UnitComponent>();
+  m_u->spawn_type = params.spawn_type;
+  m_u->health = profile.combat.health;
+  m_u->max_health = profile.combat.max_health;
+  m_u->speed = profile.combat.speed;
+  m_u->owner_id = params.player_id;
+  m_u->vision_range = profile.combat.vision_range;
+  m_u->nation_id = nation_id;
+
+  if (params.aiControlled) {
+    e->addComponent<Engine::Core::AIControlledComponent>();
+  } else {
+  }
+
+  QVector3D const tc = team_color(m_u->owner_id);
+  m_r->color[0] = tc.x();
+  m_r->color[1] = tc.y();
+  m_r->color[2] = tc.z();
+
+  m_mv = e->addComponent<Engine::Core::MovementComponent>();
+  if (m_mv != nullptr) {
+    m_mv->goalX = params.position.x();
+    m_mv->goalY = params.position.z();
+    m_mv->target_x = params.position.x();
+    m_mv->target_y = params.position.z();
+  }
+
+  m_atk = e->addComponent<Engine::Core::AttackComponent>();
+
+  m_atk->range = profile.combat.ranged_range;
+  m_atk->damage = profile.combat.ranged_damage;
+  m_atk->cooldown = profile.combat.ranged_cooldown;
+
+  m_atk->meleeRange = profile.combat.melee_range;
+  m_atk->meleeDamage = profile.combat.melee_damage;
+  m_atk->meleeCooldown = profile.combat.melee_cooldown;
+
+  m_atk->preferredMode = profile.combat.can_ranged
+                             ? Engine::Core::AttackComponent::CombatMode::Auto
+                             : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->currentMode = profile.combat.can_ranged
+                           ? Engine::Core::AttackComponent::CombatMode::Ranged
+                           : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->canRanged = profile.combat.can_ranged;
+  m_atk->canMelee = profile.combat.can_melee;
+  m_atk->max_heightDifference = 2.0F;
+
+  Engine::Core::EventManager::instance().publish(
+      Engine::Core::UnitSpawnedEvent(m_id, m_u->owner_id, m_u->spawn_type));
+}
+
+} // namespace Game::Units

+ 17 - 0
game/units/horse_spearman.h

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

+ 28 - 0
game/units/spawn_type.h

@@ -14,6 +14,8 @@ enum class SpawnType : std::uint8_t {
   Knight,
   Spearman,
   MountedKnight,
+  HorseArcher,
+  HorseSpearman,
   Barracks
 };
 
@@ -27,6 +29,10 @@ inline auto spawn_typeToQString(SpawnType type) -> QString {
     return QStringLiteral("spearman");
   case SpawnType::MountedKnight:
     return QStringLiteral("horse_swordsman");
+  case SpawnType::HorseArcher:
+    return QStringLiteral("horse_archer");
+  case SpawnType::HorseSpearman:
+    return QStringLiteral("horse_spearman");
   case SpawnType::Barracks:
     return QStringLiteral("barracks");
   }
@@ -56,6 +62,14 @@ inline auto tryParseSpawnType(const QString &value, SpawnType &out) -> bool {
     out = SpawnType::MountedKnight;
     return true;
   }
+  if (lowered == QStringLiteral("horse_archer")) {
+    out = SpawnType::HorseArcher;
+    return true;
+  }
+  if (lowered == QStringLiteral("horse_spearman")) {
+    out = SpawnType::HorseSpearman;
+    return true;
+  }
   if (lowered == QStringLiteral("barracks")) {
     out = SpawnType::Barracks;
     return true;
@@ -77,6 +91,12 @@ spawn_typeFromString(const std::string &str) -> std::optional<SpawnType> {
   if (str == "horse_swordsman") {
     return SpawnType::MountedKnight;
   }
+  if (str == "horse_archer") {
+    return SpawnType::HorseArcher;
+  }
+  if (str == "horse_spearman") {
+    return SpawnType::HorseSpearman;
+  }
   if (str == "barracks") {
     return SpawnType::Barracks;
   }
@@ -101,6 +121,10 @@ inline auto spawn_typeToTroopType(SpawnType type) -> std::optional<TroopType> {
     return TroopType::Spearman;
   case SpawnType::MountedKnight:
     return TroopType::MountedKnight;
+  case SpawnType::HorseArcher:
+    return TroopType::HorseArcher;
+  case SpawnType::HorseSpearman:
+    return TroopType::HorseSpearman;
   case SpawnType::Barracks:
     return std::nullopt;
   }
@@ -117,6 +141,10 @@ inline auto spawn_typeFromTroopType(TroopType type) -> SpawnType {
     return SpawnType::Spearman;
   case TroopType::MountedKnight:
     return SpawnType::MountedKnight;
+  case TroopType::HorseArcher:
+    return SpawnType::HorseArcher;
+  case TroopType::HorseSpearman:
+    return SpawnType::HorseSpearman;
   }
   return SpawnType::Archer;
 }

+ 15 - 1
game/units/troop_type.h

@@ -9,7 +9,7 @@
 
 namespace Game::Units {
 
-enum class TroopType { Archer, Swordsman, Spearman, MountedKnight };
+enum class TroopType { Archer, Swordsman, Spearman, MountedKnight, HorseArcher, HorseSpearman };
 
 inline auto troop_typeToQString(TroopType type) -> QString {
   switch (type) {
@@ -21,6 +21,10 @@ inline auto troop_typeToQString(TroopType type) -> QString {
     return QStringLiteral("spearman");
   case TroopType::MountedKnight:
     return QStringLiteral("horse_swordsman");
+  case TroopType::HorseArcher:
+    return QStringLiteral("horse_archer");
+  case TroopType::HorseSpearman:
+    return QStringLiteral("horse_spearman");
   }
   return QStringLiteral("archer");
 }
@@ -49,6 +53,16 @@ inline auto tryParseTroopType(const QString &value, TroopType &out) -> bool {
     out = TroopType::MountedKnight;
     return true;
   }
+  if (lowered == QStringLiteral("horse_archer") ||
+      lowered == QStringLiteral("horsearcher")) {
+    out = TroopType::HorseArcher;
+    return true;
+  }
+  if (lowered == QStringLiteral("horse_spearman") ||
+      lowered == QStringLiteral("horsespearman")) {
+    out = TroopType::HorseSpearman;
+    return true;
+  }
   return false;
 }
 

+ 8 - 0
render/CMakeLists.txt

@@ -53,9 +53,17 @@ add_library(render_gl STATIC
     horse/horse_animation_controller.cpp
     entity/horse_renderer.cpp
     entity/mounted_knight_renderer_base.cpp
+    entity/horse_archer_renderer_base.cpp
+    entity/horse_spearman_renderer_base.cpp
     entity/nations/roman/horse_swordsman_renderer.cpp
     entity/nations/carthage/horse_swordsman_renderer.cpp
     entity/nations/kingdom/horse_swordsman_renderer.cpp
+    entity/nations/roman/horse_archer_renderer.cpp
+    entity/nations/carthage/horse_archer_renderer.cpp
+    entity/nations/kingdom/horse_archer_renderer.cpp
+    entity/nations/roman/horse_spearman_renderer.cpp
+    entity/nations/carthage/horse_spearman_renderer.cpp
+    entity/nations/kingdom/horse_spearman_renderer.cpp
     entity/barracks_renderer.cpp
     entity/nations/kingdom/barracks_renderer.cpp
     entity/nations/roman/barracks_renderer.cpp

+ 256 - 0
render/entity/horse_archer_renderer_base.cpp

@@ -0,0 +1,256 @@
+#include "horse_archer_renderer_base.h"
+
+#include "../equipment/equipment_registry.h"
+#include "../equipment/weapons/bow_renderer.h"
+#include "../equipment/weapons/quiver_renderer.h"
+#include "../humanoid/humanoid_math.h"
+#include "../humanoid/humanoid_specs.h"
+#include "../humanoid/mounted_pose_controller.h"
+#include "../palette.h"
+
+#include "../../game/core/component.h"
+#include "../../game/core/entity.h"
+#include "../../game/systems/nation_id.h"
+
+#include "mounted_knight_pose.h"
+#include "renderer_constants.h"
+
+#include <QVector3D>
+
+#include <algorithm>
+#include <cmath>
+#include <utility>
+
+namespace Render::GL {
+
+namespace {
+
+constexpr QVector3D k_default_proportion_scale{0.92F, 0.88F, 0.96F};
+
+}
+
+HorseArcherRendererBase::HorseArcherRendererBase(
+    HorseArcherRendererConfig config)
+    : m_config(std::move(config)) {
+  m_config.has_bow = m_config.has_bow && !m_config.bow_equipment_id.empty();
+  if (!m_config.has_bow) {
+    m_config.bow_equipment_id.clear();
+  }
+
+  m_config.has_quiver =
+      m_config.has_quiver && !m_config.quiver_equipment_id.empty();
+  if (!m_config.has_quiver) {
+    m_config.quiver_equipment_id.clear();
+  }
+
+  m_horseRenderer.setAttachments(m_config.horse_attachments);
+}
+
+auto HorseArcherRendererBase::get_proportion_scaling() const -> QVector3D {
+  return k_default_proportion_scale;
+}
+
+auto HorseArcherRendererBase::get_mount_scale() const -> float {
+  return m_config.mount_scale;
+}
+
+void HorseArcherRendererBase::adjust_variation(
+    const DrawContext &, uint32_t, VariationParams &variation) const {
+  variation.height_scale = 0.88F;
+  variation.bulk_scale = 0.78F;
+  variation.stance_width = 0.60F;
+  variation.arm_swing_amp = 0.45F;
+  variation.walk_speed_mult = 1.0F;
+  variation.posture_slump = 0.0F;
+  variation.shoulder_tilt = 0.0F;
+}
+
+void HorseArcherRendererBase::get_variant(const DrawContext &ctx,
+                                          uint32_t seed,
+                                          HumanoidVariant &v) const {
+  QVector3D const team_tint = resolveTeamTint(ctx);
+  v.palette = makeHumanoidPalette(team_tint, seed);
+}
+
+auto HorseArcherRendererBase::get_scaled_horse_dimensions(uint32_t seed) const
+    -> HorseDimensions {
+  HorseDimensions dims = makeHorseDimensions(seed);
+  scaleHorseDimensions(dims, get_mount_scale());
+  return dims;
+}
+
+void HorseArcherRendererBase::customize_pose(
+    const DrawContext &ctx, const HumanoidAnimationContext &anim_ctx,
+    uint32_t seed, HumanoidPose &pose) const {
+  const AnimationInputs &anim = anim_ctx.inputs;
+
+  uint32_t horse_seed = seed;
+  if (ctx.entity != nullptr) {
+    horse_seed = static_cast<uint32_t>(reinterpret_cast<uintptr_t>(ctx.entity) &
+                                       0xFFFFFFFFU);
+  }
+
+  HorseDimensions dims = get_scaled_horse_dimensions(horse_seed);
+  HorseProfile mount_profile{};
+  mount_profile.dims = dims;
+  MountedAttachmentFrame mount = compute_mount_frame(mount_profile);
+  tuneMountedKnightFrame(dims, mount);
+  HorseMotionSample const motion =
+      evaluate_horse_motion(mount_profile, anim, anim_ctx);
+  apply_mount_vertical_offset(mount, motion.bob);
+
+  m_last_pose = &pose;
+  m_last_mount = mount;
+
+  ReinState const reins = compute_rein_state(horse_seed, anim_ctx);
+  m_last_rein_state = reins;
+  m_has_last_reins = true;
+
+  MountedPoseController mounted_controller(pose, anim_ctx);
+
+  mounted_controller.mountOnHorse(mount);
+
+  if (anim.is_attacking && !anim.is_melee) {
+    float const attack_phase =
+        std::fmod(anim.time * ARCHER_INV_ATTACK_CYCLE_TIME, 1.0F);
+    mounted_controller.ridingBowShot(mount, attack_phase);
+  } else {
+    mounted_controller.ridingIdle(mount);
+  }
+
+  applyMountedKnightLowerBody(dims, mount, anim_ctx, pose);
+}
+
+auto HorseArcherRendererBase::compute_horse_archer_extras(
+    uint32_t seed, const HumanoidVariant &v,
+    const HorseDimensions &dims) const -> HorseArcherExtras {
+  HorseArcherExtras extras;
+  extras.horse_profile =
+      makeHorseProfile(seed, v.palette.leather, v.palette.cloth);
+  extras.horse_profile.dims = dims;
+  extras.bow_length = 0.75F + (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.10F;
+
+  return extras;
+}
+
+void HorseArcherRendererBase::addAttachments(
+    const DrawContext &ctx, const HumanoidVariant &v, const HumanoidPose &pose,
+    const HumanoidAnimationContext &anim_ctx, ISubmitter &out) const {
+  uint32_t horse_seed = 0U;
+  if (ctx.entity != nullptr) {
+    horse_seed = static_cast<uint32_t>(reinterpret_cast<uintptr_t>(ctx.entity) &
+                                       0xFFFFFFFFU);
+  }
+
+  HorseArcherExtras extras;
+  auto it = m_extras_cache.find(horse_seed);
+  if (it != m_extras_cache.end()) {
+    extras = it->second;
+  } else {
+    HorseDimensions dims = get_scaled_horse_dimensions(horse_seed);
+    extras = compute_horse_archer_extras(horse_seed, v, dims);
+    m_extras_cache[horse_seed] = extras;
+
+    if (m_extras_cache.size() > MAX_EXTRAS_CACHE_SIZE) {
+      m_extras_cache.clear();
+    }
+  }
+
+  const bool is_current_pose = (m_last_pose == &pose);
+  const MountedAttachmentFrame *mount_ptr =
+      (is_current_pose) ? &m_last_mount : nullptr;
+  const ReinState *rein_ptr =
+      (is_current_pose && m_has_last_reins) ? &m_last_rein_state : nullptr;
+  const AnimationInputs &anim = anim_ctx.inputs;
+
+  m_horseRenderer.render(ctx, anim, anim_ctx, extras.horse_profile, mount_ptr,
+                         rein_ptr, out);
+  m_last_pose = nullptr;
+  m_has_last_reins = false;
+
+  auto &registry = EquipmentRegistry::instance();
+
+  if (m_config.has_bow && !m_config.bow_equipment_id.empty()) {
+    auto bow =
+        registry.get(EquipmentCategory::Weapon, m_config.bow_equipment_id);
+    if (bow) {
+      BowRenderConfig bow_config;
+      bow_config.string_color = QVector3D(0.30F, 0.30F, 0.32F);
+      bow_config.metal_color = m_config.metal_color;
+      bow_config.fletching_color = m_config.fletching_color;
+      bow_config.bow_top_y = HumanProportions::SHOULDER_Y + 0.55F;
+      bow_config.bow_bot_y = HumanProportions::WAIST_Y - 0.25F;
+      bow_config.bow_x = 0.0F;
+
+      if (auto *bow_renderer = dynamic_cast<BowRenderer *>(bow.get())) {
+        bow_renderer->setConfig(bow_config);
+      }
+      bow->render(ctx, pose.body_frames, v.palette, anim_ctx, out);
+    }
+  }
+
+  if (m_config.has_quiver && !m_config.quiver_equipment_id.empty()) {
+    auto quiver =
+        registry.get(EquipmentCategory::Weapon, m_config.quiver_equipment_id);
+    if (quiver) {
+      QuiverRenderConfig quiver_config;
+      quiver_config.fletching_color = m_config.fletching_color;
+      quiver_config.quiver_radius = HumanProportions::HEAD_RADIUS * 0.45F;
+
+      if (auto *quiver_renderer = dynamic_cast<QuiverRenderer *>(quiver.get())) {
+        quiver_renderer->setConfig(quiver_config);
+      }
+      quiver->render(ctx, pose.body_frames, v.palette, anim_ctx, out);
+    }
+  }
+}
+
+void HorseArcherRendererBase::draw_helmet(const DrawContext &ctx,
+                                          const HumanoidVariant &v,
+                                          const HumanoidPose &pose,
+                                          ISubmitter &out) const {
+  if (m_config.helmet_equipment_id.empty()) {
+    return;
+  }
+
+  auto &registry = EquipmentRegistry::instance();
+  auto helmet =
+      registry.get(EquipmentCategory::Helmet, m_config.helmet_equipment_id);
+  if (helmet) {
+    HumanoidAnimationContext anim_ctx{};
+    helmet->render(ctx, pose.body_frames, v.palette, anim_ctx, out);
+  }
+}
+
+void HorseArcherRendererBase::draw_armor(const DrawContext &ctx,
+                                         const HumanoidVariant &v,
+                                         const HumanoidPose &pose,
+                                         const HumanoidAnimationContext &anim,
+                                         ISubmitter &out) const {
+  if (m_config.armor_equipment_id.empty()) {
+    return;
+  }
+
+  auto &registry = EquipmentRegistry::instance();
+  auto armor =
+      registry.get(EquipmentCategory::Armor, m_config.armor_equipment_id);
+  if (armor) {
+    armor->render(ctx, pose.body_frames, v.palette, anim, out);
+  }
+}
+
+auto HorseArcherRendererBase::resolve_shader_key(const DrawContext &ctx) const
+    -> QString {
+  std::string nation;
+  if (ctx.entity != nullptr) {
+    if (auto *unit = ctx.entity->getComponent<Engine::Core::UnitComponent>()) {
+      nation = Game::Systems::nationIDToString(unit->nation_id);
+    }
+  }
+  if (!nation.empty()) {
+    return QString::fromStdString(std::string("horse_archer_") + nation);
+  }
+  return QStringLiteral("horse_archer");
+}
+
+} // namespace Render::GL

+ 85 - 0
render/entity/horse_archer_renderer_base.h

@@ -0,0 +1,85 @@
+#pragma once
+
+#include "../equipment/horse/i_horse_equipment_renderer.h"
+#include "../humanoid/rig.h"
+#include "horse_renderer.h"
+
+#include <QString>
+#include <QVector3D>
+
+#include <memory>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+namespace Render::GL {
+
+struct HorseArcherRendererConfig {
+  std::string bow_equipment_id;
+  std::string quiver_equipment_id;
+  std::string helmet_equipment_id;
+  std::string armor_equipment_id;
+  QVector3D metal_color{0.72F, 0.73F, 0.78F};
+  QVector3D fletching_color{0.85F, 0.40F, 0.40F};
+  float mount_scale = 0.75F;
+  bool has_bow = true;
+  bool has_quiver = true;
+  std::vector<std::shared_ptr<IHorseEquipmentRenderer>> horse_attachments;
+};
+
+class HorseArcherRendererBase : public HumanoidRendererBase {
+public:
+  explicit HorseArcherRendererBase(HorseArcherRendererConfig config);
+  HorseArcherRendererBase(const HorseArcherRendererBase &) = delete;
+  HorseArcherRendererBase &
+  operator=(const HorseArcherRendererBase &) = delete;
+  HorseArcherRendererBase(HorseArcherRendererBase &&) = delete;
+  HorseArcherRendererBase &operator=(HorseArcherRendererBase &&) = delete;
+  ~HorseArcherRendererBase() override = default;
+
+  auto get_proportion_scaling() const -> QVector3D override;
+  auto get_mount_scale() const -> float override;
+  void adjust_variation(const DrawContext &, uint32_t,
+                        VariationParams &variation) const override;
+  void get_variant(const DrawContext &ctx, uint32_t seed,
+                   HumanoidVariant &v) const override;
+  void customize_pose(const DrawContext &ctx,
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override;
+  void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
+                      const HumanoidPose &pose,
+                      const HumanoidAnimationContext &anim_ctx,
+                      ISubmitter &out) const override;
+  void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
+                   const HumanoidPose &pose, ISubmitter &out) const override;
+  void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override;
+
+  auto resolve_shader_key(const DrawContext &ctx) const -> QString;
+
+protected:
+  const HorseArcherRendererConfig &config() const { return m_config; }
+
+private:
+  struct HorseArcherExtras {
+    HorseProfile horse_profile;
+    float bow_length = 0.85F;
+  };
+
+  auto get_scaled_horse_dimensions(uint32_t seed) const -> HorseDimensions;
+  auto compute_horse_archer_extras(uint32_t seed, const HumanoidVariant &v,
+                                    const HorseDimensions &dims) const
+      -> HorseArcherExtras;
+
+  HorseArcherRendererConfig m_config;
+  mutable std::unordered_map<uint32_t, HorseArcherExtras> m_extras_cache;
+  mutable const HumanoidPose *m_last_pose = nullptr;
+  mutable MountedAttachmentFrame m_last_mount{};
+  mutable ReinState m_last_rein_state{};
+  mutable bool m_has_last_reins = false;
+  HorseRenderer m_horseRenderer;
+};
+
+} // namespace Render::GL

+ 259 - 0
render/entity/horse_spearman_renderer_base.cpp

@@ -0,0 +1,259 @@
+#include "horse_spearman_renderer_base.h"
+
+#include "../equipment/equipment_registry.h"
+#include "../equipment/weapons/shield_renderer.h"
+#include "../equipment/weapons/spear_renderer.h"
+#include "../humanoid/humanoid_math.h"
+#include "../humanoid/humanoid_specs.h"
+#include "../humanoid/mounted_pose_controller.h"
+#include "../palette.h"
+
+#include "../../game/core/component.h"
+#include "../../game/core/entity.h"
+#include "../../game/systems/nation_id.h"
+
+#include "mounted_knight_pose.h"
+#include "renderer_constants.h"
+
+#include <QVector3D>
+
+#include <algorithm>
+#include <cmath>
+#include <utility>
+
+namespace Render::GL {
+
+namespace {
+
+constexpr QVector3D k_default_proportion_scale{0.96F, 0.90F, 0.98F};
+
+}
+
+HorseSpearmanRendererBase::HorseSpearmanRendererBase(
+    HorseSpearmanRendererConfig config)
+    : m_config(std::move(config)) {
+  m_config.has_spear =
+      m_config.has_spear && !m_config.spear_equipment_id.empty();
+  if (!m_config.has_spear) {
+    m_config.spear_equipment_id.clear();
+  }
+
+  m_config.has_shield =
+      m_config.has_shield && !m_config.shield_equipment_id.empty();
+  if (!m_config.has_shield) {
+    m_config.shield_equipment_id.clear();
+  }
+
+  m_horseRenderer.setAttachments(m_config.horse_attachments);
+}
+
+auto HorseSpearmanRendererBase::get_proportion_scaling() const -> QVector3D {
+  return k_default_proportion_scale;
+}
+
+auto HorseSpearmanRendererBase::get_mount_scale() const -> float {
+  return m_config.mount_scale;
+}
+
+void HorseSpearmanRendererBase::adjust_variation(
+    const DrawContext &, uint32_t, VariationParams &variation) const {
+  variation.height_scale = 0.90F;
+  variation.bulk_scale = 0.85F;
+  variation.stance_width = 0.60F;
+  variation.arm_swing_amp = 0.45F;
+  variation.walk_speed_mult = 1.0F;
+  variation.posture_slump = 0.0F;
+  variation.shoulder_tilt = 0.0F;
+}
+
+void HorseSpearmanRendererBase::get_variant(const DrawContext &ctx,
+                                            uint32_t seed,
+                                            HumanoidVariant &v) const {
+  QVector3D const team_tint = resolveTeamTint(ctx);
+  v.palette = makeHumanoidPalette(team_tint, seed);
+}
+
+auto HorseSpearmanRendererBase::get_scaled_horse_dimensions(uint32_t seed) const
+    -> HorseDimensions {
+  HorseDimensions dims = makeHorseDimensions(seed);
+  scaleHorseDimensions(dims, get_mount_scale());
+  return dims;
+}
+
+void HorseSpearmanRendererBase::customize_pose(
+    const DrawContext &ctx, const HumanoidAnimationContext &anim_ctx,
+    uint32_t seed, HumanoidPose &pose) const {
+  const AnimationInputs &anim = anim_ctx.inputs;
+
+  uint32_t horse_seed = seed;
+  if (ctx.entity != nullptr) {
+    horse_seed = static_cast<uint32_t>(reinterpret_cast<uintptr_t>(ctx.entity) &
+                                       0xFFFFFFFFU);
+  }
+
+  HorseDimensions dims = get_scaled_horse_dimensions(horse_seed);
+  HorseProfile mount_profile{};
+  mount_profile.dims = dims;
+  MountedAttachmentFrame mount = compute_mount_frame(mount_profile);
+  tuneMountedKnightFrame(dims, mount);
+  HorseMotionSample const motion =
+      evaluate_horse_motion(mount_profile, anim, anim_ctx);
+  apply_mount_vertical_offset(mount, motion.bob);
+
+  m_last_pose = &pose;
+  m_last_mount = mount;
+
+  ReinState const reins = compute_rein_state(horse_seed, anim_ctx);
+  m_last_rein_state = reins;
+  m_has_last_reins = true;
+
+  MountedPoseController mounted_controller(pose, anim_ctx);
+
+  mounted_controller.mountOnHorse(mount);
+
+  float const speed_norm = anim_ctx.locomotion_normalized_speed();
+  bool const is_charging = speed_norm > 0.65F;
+
+  if (anim.is_attacking && anim.is_melee) {
+    if (is_charging) {
+      mounted_controller.ridingCharging(mount, 1.0F);
+      mounted_controller.holdSpearMounted(mount, SpearGrip::COUCHED);
+    } else {
+      float const attack_phase =
+          std::fmod(anim.time * SPEARMAN_INV_ATTACK_CYCLE_TIME, 1.0F);
+      mounted_controller.ridingSpearThrust(mount, attack_phase);
+    }
+  } else {
+    mounted_controller.ridingIdle(mount);
+  }
+
+  applyMountedKnightLowerBody(dims, mount, anim_ctx, pose);
+}
+
+auto HorseSpearmanRendererBase::compute_horse_spearman_extras(
+    uint32_t seed, const HumanoidVariant &v,
+    const HorseDimensions &dims) const -> HorseSpearmanExtras {
+  HorseSpearmanExtras extras;
+  extras.horse_profile =
+      makeHorseProfile(seed, v.palette.leather, v.palette.cloth);
+  extras.horse_profile.dims = dims;
+  extras.spear_length = 1.15F + (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.10F;
+  extras.spear_shaft_radius = 0.018F + (hash_01(seed ^ 0x7777U) - 0.5F) * 0.003F;
+
+  return extras;
+}
+
+void HorseSpearmanRendererBase::addAttachments(
+    const DrawContext &ctx, const HumanoidVariant &v, const HumanoidPose &pose,
+    const HumanoidAnimationContext &anim_ctx, ISubmitter &out) const {
+  uint32_t horse_seed = 0U;
+  if (ctx.entity != nullptr) {
+    horse_seed = static_cast<uint32_t>(reinterpret_cast<uintptr_t>(ctx.entity) &
+                                       0xFFFFFFFFU);
+  }
+
+  HorseSpearmanExtras extras;
+  auto it = m_extras_cache.find(horse_seed);
+  if (it != m_extras_cache.end()) {
+    extras = it->second;
+  } else {
+    HorseDimensions dims = get_scaled_horse_dimensions(horse_seed);
+    extras = compute_horse_spearman_extras(horse_seed, v, dims);
+    m_extras_cache[horse_seed] = extras;
+
+    if (m_extras_cache.size() > MAX_EXTRAS_CACHE_SIZE) {
+      m_extras_cache.clear();
+    }
+  }
+
+  const bool is_current_pose = (m_last_pose == &pose);
+  const MountedAttachmentFrame *mount_ptr =
+      (is_current_pose) ? &m_last_mount : nullptr;
+  const ReinState *rein_ptr =
+      (is_current_pose && m_has_last_reins) ? &m_last_rein_state : nullptr;
+  const AnimationInputs &anim = anim_ctx.inputs;
+
+  m_horseRenderer.render(ctx, anim, anim_ctx, extras.horse_profile, mount_ptr,
+                         rein_ptr, out);
+  m_last_pose = nullptr;
+  m_has_last_reins = false;
+
+  auto &registry = EquipmentRegistry::instance();
+
+  if (m_config.has_spear && !m_config.spear_equipment_id.empty()) {
+    auto spear =
+        registry.get(EquipmentCategory::Weapon, m_config.spear_equipment_id);
+    if (spear) {
+      SpearRenderConfig spear_config;
+      spear_config.shaft_color =
+          v.palette.leather * QVector3D(0.85F, 0.75F, 0.65F);
+      spear_config.spearhead_color = m_config.metal_color;
+      spear_config.spear_length = extras.spear_length;
+      spear_config.shaft_radius = extras.spear_shaft_radius;
+      spear_config.spearhead_length = 0.18F;
+
+      if (auto *spear_renderer = dynamic_cast<SpearRenderer *>(spear.get())) {
+        spear_renderer->setConfig(spear_config);
+      }
+      spear->render(ctx, pose.body_frames, v.palette, anim_ctx, out);
+    }
+  }
+
+  if (m_config.has_shield && !m_config.shield_equipment_id.empty()) {
+    auto shield =
+        registry.get(EquipmentCategory::Weapon, m_config.shield_equipment_id);
+    if (shield) {
+      shield->render(ctx, pose.body_frames, v.palette, anim_ctx, out);
+    }
+  }
+}
+
+void HorseSpearmanRendererBase::draw_helmet(const DrawContext &ctx,
+                                            const HumanoidVariant &v,
+                                            const HumanoidPose &pose,
+                                            ISubmitter &out) const {
+  if (m_config.helmet_equipment_id.empty()) {
+    return;
+  }
+
+  auto &registry = EquipmentRegistry::instance();
+  auto helmet =
+      registry.get(EquipmentCategory::Helmet, m_config.helmet_equipment_id);
+  if (helmet) {
+    HumanoidAnimationContext anim_ctx{};
+    helmet->render(ctx, pose.body_frames, v.palette, anim_ctx, out);
+  }
+}
+
+void HorseSpearmanRendererBase::draw_armor(const DrawContext &ctx,
+                                           const HumanoidVariant &v,
+                                           const HumanoidPose &pose,
+                                           const HumanoidAnimationContext &anim,
+                                           ISubmitter &out) const {
+  if (m_config.armor_equipment_id.empty()) {
+    return;
+  }
+
+  auto &registry = EquipmentRegistry::instance();
+  auto armor =
+      registry.get(EquipmentCategory::Armor, m_config.armor_equipment_id);
+  if (armor) {
+    armor->render(ctx, pose.body_frames, v.palette, anim, out);
+  }
+}
+
+auto HorseSpearmanRendererBase::resolve_shader_key(const DrawContext &ctx) const
+    -> QString {
+  std::string nation;
+  if (ctx.entity != nullptr) {
+    if (auto *unit = ctx.entity->getComponent<Engine::Core::UnitComponent>()) {
+      nation = Game::Systems::nationIDToString(unit->nation_id);
+    }
+  }
+  if (!nation.empty()) {
+    return QString::fromStdString(std::string("horse_spearman_") + nation);
+  }
+  return QStringLiteral("horse_spearman");
+}
+
+} // namespace Render::GL

+ 85 - 0
render/entity/horse_spearman_renderer_base.h

@@ -0,0 +1,85 @@
+#pragma once
+
+#include "../equipment/horse/i_horse_equipment_renderer.h"
+#include "../humanoid/rig.h"
+#include "horse_renderer.h"
+
+#include <QString>
+#include <QVector3D>
+
+#include <memory>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+namespace Render::GL {
+
+struct HorseSpearmanRendererConfig {
+  std::string spear_equipment_id;
+  std::string shield_equipment_id;
+  std::string helmet_equipment_id;
+  std::string armor_equipment_id;
+  QVector3D metal_color{0.72F, 0.73F, 0.78F};
+  float mount_scale = 0.75F;
+  bool has_spear = true;
+  bool has_shield = false;
+  std::vector<std::shared_ptr<IHorseEquipmentRenderer>> horse_attachments;
+};
+
+class HorseSpearmanRendererBase : public HumanoidRendererBase {
+public:
+  explicit HorseSpearmanRendererBase(HorseSpearmanRendererConfig config);
+  HorseSpearmanRendererBase(const HorseSpearmanRendererBase &) = delete;
+  HorseSpearmanRendererBase &
+  operator=(const HorseSpearmanRendererBase &) = delete;
+  HorseSpearmanRendererBase(HorseSpearmanRendererBase &&) = delete;
+  HorseSpearmanRendererBase &operator=(HorseSpearmanRendererBase &&) = delete;
+  ~HorseSpearmanRendererBase() override = default;
+
+  auto get_proportion_scaling() const -> QVector3D override;
+  auto get_mount_scale() const -> float override;
+  void adjust_variation(const DrawContext &, uint32_t,
+                        VariationParams &variation) const override;
+  void get_variant(const DrawContext &ctx, uint32_t seed,
+                   HumanoidVariant &v) const override;
+  void customize_pose(const DrawContext &ctx,
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override;
+  void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
+                      const HumanoidPose &pose,
+                      const HumanoidAnimationContext &anim_ctx,
+                      ISubmitter &out) const override;
+  void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
+                   const HumanoidPose &pose, ISubmitter &out) const override;
+  void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override;
+
+  auto resolve_shader_key(const DrawContext &ctx) const -> QString;
+
+protected:
+  const HorseSpearmanRendererConfig &config() const { return m_config; }
+
+private:
+  struct HorseSpearmanExtras {
+    HorseProfile horse_profile;
+    float spear_length = 1.20F;
+    float spear_shaft_radius = 0.020F;
+  };
+
+  auto get_scaled_horse_dimensions(uint32_t seed) const -> HorseDimensions;
+  auto compute_horse_spearman_extras(uint32_t seed, const HumanoidVariant &v,
+                                      const HorseDimensions &dims) const
+      -> HorseSpearmanExtras;
+
+  HorseSpearmanRendererConfig m_config;
+  mutable std::unordered_map<uint32_t, HorseSpearmanExtras> m_extras_cache;
+  mutable const HumanoidPose *m_last_pose = nullptr;
+  mutable MountedAttachmentFrame m_last_mount{};
+  mutable ReinState m_last_rein_state{};
+  mutable bool m_has_last_reins = false;
+  HorseRenderer m_horseRenderer;
+};
+
+} // namespace Render::GL

+ 58 - 0
render/entity/nations/carthage/horse_archer_renderer.cpp

@@ -0,0 +1,58 @@
+#include "horse_archer_renderer.h"
+
+#include "../../../equipment/horse/saddles/carthage_saddle_renderer.h"
+#include "../../../equipment/horse/tack/reins_renderer.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/shader.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../horse_archer_renderer_base.h"
+
+#include <memory>
+
+namespace Render::GL::Carthage {
+namespace {
+
+auto make_horse_archer_config() -> HorseArcherRendererConfig {
+  HorseArcherRendererConfig config;
+  config.bow_equipment_id = "bow_carthage";
+  config.quiver_equipment_id = "quiver";
+  config.helmet_equipment_id = "carthage_light";
+  config.armor_equipment_id = "armor_light_carthage";
+  config.fletching_color = {0.85F, 0.40F, 0.40F};
+  config.horse_attachments.emplace_back(
+      std::make_shared<CarthageSaddleRenderer>());
+  config.horse_attachments.emplace_back(std::make_shared<ReinsRenderer>());
+  return config;
+}
+
+} // namespace
+
+void register_horse_archer_renderer(EntityRendererRegistry &registry) {
+  registry.register_renderer(
+      "troops/carthage/horse_archer",
+      [](const DrawContext &ctx, ISubmitter &out) {
+        static HorseArcherRendererBase const static_renderer(
+            make_horse_archer_config());
+        Shader *horse_archer_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          horse_archer_shader = ctx.backend->shader(shader_key);
+          if (horse_archer_shader == nullptr) {
+            horse_archer_shader =
+                ctx.backend->shader(QStringLiteral("horse_archer"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) &&
+            (horse_archer_shader != nullptr)) {
+          scene_renderer->setCurrentShader(horse_archer_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Carthage

+ 9 - 0
render/entity/nations/carthage/horse_archer_renderer.h

@@ -0,0 +1,9 @@
+#pragma once
+
+#include "../../registry.h"
+
+namespace Render::GL::Carthage {
+
+void register_horse_archer_renderer(EntityRendererRegistry &registry);
+
+}

+ 56 - 0
render/entity/nations/carthage/horse_spearman_renderer.cpp

@@ -0,0 +1,56 @@
+#include "horse_spearman_renderer.h"
+
+#include "../../../equipment/horse/saddles/carthage_saddle_renderer.h"
+#include "../../../equipment/horse/tack/reins_renderer.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/shader.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../horse_spearman_renderer_base.h"
+
+#include <memory>
+
+namespace Render::GL::Carthage {
+namespace {
+
+auto make_horse_spearman_config() -> HorseSpearmanRendererConfig {
+  HorseSpearmanRendererConfig config;
+  config.spear_equipment_id = "spear";
+  config.helmet_equipment_id = "carthage_heavy";
+  config.armor_equipment_id = "armor_heavy_carthage";
+  config.horse_attachments.emplace_back(
+      std::make_shared<CarthageSaddleRenderer>());
+  config.horse_attachments.emplace_back(std::make_shared<ReinsRenderer>());
+  return config;
+}
+
+} // namespace
+
+void register_horse_spearman_renderer(EntityRendererRegistry &registry) {
+  registry.register_renderer(
+      "troops/carthage/horse_spearman",
+      [](const DrawContext &ctx, ISubmitter &out) {
+        static HorseSpearmanRendererBase const static_renderer(
+            make_horse_spearman_config());
+        Shader *horse_spearman_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          horse_spearman_shader = ctx.backend->shader(shader_key);
+          if (horse_spearman_shader == nullptr) {
+            horse_spearman_shader =
+                ctx.backend->shader(QStringLiteral("horse_spearman"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) &&
+            (horse_spearman_shader != nullptr)) {
+          scene_renderer->setCurrentShader(horse_spearman_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Carthage

+ 9 - 0
render/entity/nations/carthage/horse_spearman_renderer.h

@@ -0,0 +1,9 @@
+#pragma once
+
+#include "../../registry.h"
+
+namespace Render::GL::Carthage {
+
+void register_horse_spearman_renderer(EntityRendererRegistry &registry);
+
+}

+ 58 - 0
render/entity/nations/kingdom/horse_archer_renderer.cpp

@@ -0,0 +1,58 @@
+#include "horse_archer_renderer.h"
+
+#include "../../../equipment/horse/saddles/light_cavalry_saddle_renderer.h"
+#include "../../../equipment/horse/tack/reins_renderer.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/shader.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../horse_archer_renderer_base.h"
+
+#include <memory>
+
+namespace Render::GL::Kingdom {
+namespace {
+
+auto make_horse_archer_config() -> HorseArcherRendererConfig {
+  HorseArcherRendererConfig config;
+  config.bow_equipment_id = "bow_kingdom";
+  config.quiver_equipment_id = "quiver";
+  config.helmet_equipment_id = "kingdom_light";
+  config.armor_equipment_id = "kingdom_light_armor";
+  config.fletching_color = {0.85F, 0.40F, 0.40F};
+  config.horse_attachments.emplace_back(
+      std::make_shared<LightCavalrySaddleRenderer>());
+  config.horse_attachments.emplace_back(std::make_shared<ReinsRenderer>());
+  return config;
+}
+
+} // namespace
+
+void register_horse_archer_renderer(EntityRendererRegistry &registry) {
+  registry.register_renderer(
+      "troops/kingdom/horse_archer",
+      [](const DrawContext &ctx, ISubmitter &out) {
+        static HorseArcherRendererBase const static_renderer(
+            make_horse_archer_config());
+        Shader *horse_archer_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          horse_archer_shader = ctx.backend->shader(shader_key);
+          if (horse_archer_shader == nullptr) {
+            horse_archer_shader =
+                ctx.backend->shader(QStringLiteral("horse_archer"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) &&
+            (horse_archer_shader != nullptr)) {
+          scene_renderer->setCurrentShader(horse_archer_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Kingdom

+ 9 - 0
render/entity/nations/kingdom/horse_archer_renderer.h

@@ -0,0 +1,9 @@
+#pragma once
+
+#include "../../registry.h"
+
+namespace Render::GL::Kingdom {
+
+void register_horse_archer_renderer(EntityRendererRegistry &registry);
+
+}

+ 56 - 0
render/entity/nations/kingdom/horse_spearman_renderer.cpp

@@ -0,0 +1,56 @@
+#include "horse_spearman_renderer.h"
+
+#include "../../../equipment/horse/saddles/light_cavalry_saddle_renderer.h"
+#include "../../../equipment/horse/tack/reins_renderer.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/shader.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../horse_spearman_renderer_base.h"
+
+#include <memory>
+
+namespace Render::GL::Kingdom {
+namespace {
+
+auto make_horse_spearman_config() -> HorseSpearmanRendererConfig {
+  HorseSpearmanRendererConfig config;
+  config.spear_equipment_id = "spear";
+  config.helmet_equipment_id = "kingdom_heavy";
+  config.armor_equipment_id = "kingdom_heavy_armor";
+  config.horse_attachments.emplace_back(
+      std::make_shared<LightCavalrySaddleRenderer>());
+  config.horse_attachments.emplace_back(std::make_shared<ReinsRenderer>());
+  return config;
+}
+
+} // namespace
+
+void register_horse_spearman_renderer(EntityRendererRegistry &registry) {
+  registry.register_renderer(
+      "troops/kingdom/horse_spearman",
+      [](const DrawContext &ctx, ISubmitter &out) {
+        static HorseSpearmanRendererBase const static_renderer(
+            make_horse_spearman_config());
+        Shader *horse_spearman_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          horse_spearman_shader = ctx.backend->shader(shader_key);
+          if (horse_spearman_shader == nullptr) {
+            horse_spearman_shader =
+                ctx.backend->shader(QStringLiteral("horse_spearman"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) &&
+            (horse_spearman_shader != nullptr)) {
+          scene_renderer->setCurrentShader(horse_spearman_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Kingdom

+ 9 - 0
render/entity/nations/kingdom/horse_spearman_renderer.h

@@ -0,0 +1,9 @@
+#pragma once
+
+#include "../../registry.h"
+
+namespace Render::GL::Kingdom {
+
+void register_horse_spearman_renderer(EntityRendererRegistry &registry);
+
+}

+ 58 - 0
render/entity/nations/roman/horse_archer_renderer.cpp

@@ -0,0 +1,58 @@
+#include "horse_archer_renderer.h"
+
+#include "../../../equipment/horse/saddles/roman_saddle_renderer.h"
+#include "../../../equipment/horse/tack/reins_renderer.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/shader.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../horse_archer_renderer_base.h"
+
+#include <memory>
+
+namespace Render::GL::Roman {
+namespace {
+
+auto make_horse_archer_config() -> HorseArcherRendererConfig {
+  HorseArcherRendererConfig config;
+  config.bow_equipment_id = "bow_roman";
+  config.quiver_equipment_id = "quiver";
+  config.helmet_equipment_id = "roman_light";
+  config.armor_equipment_id = "roman_light_armor";
+  config.fletching_color = {0.85F, 0.40F, 0.40F};
+  config.horse_attachments.emplace_back(
+      std::make_shared<RomanSaddleRenderer>());
+  config.horse_attachments.emplace_back(std::make_shared<ReinsRenderer>());
+  return config;
+}
+
+} // namespace
+
+void register_horse_archer_renderer(EntityRendererRegistry &registry) {
+  registry.register_renderer(
+      "troops/roman/horse_archer",
+      [](const DrawContext &ctx, ISubmitter &out) {
+        static HorseArcherRendererBase const static_renderer(
+            make_horse_archer_config());
+        Shader *horse_archer_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          horse_archer_shader = ctx.backend->shader(shader_key);
+          if (horse_archer_shader == nullptr) {
+            horse_archer_shader =
+                ctx.backend->shader(QStringLiteral("horse_archer"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) &&
+            (horse_archer_shader != nullptr)) {
+          scene_renderer->setCurrentShader(horse_archer_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Roman

+ 9 - 0
render/entity/nations/roman/horse_archer_renderer.h

@@ -0,0 +1,9 @@
+#pragma once
+
+#include "../../registry.h"
+
+namespace Render::GL::Roman {
+
+void register_horse_archer_renderer(EntityRendererRegistry &registry);
+
+}

+ 56 - 0
render/entity/nations/roman/horse_spearman_renderer.cpp

@@ -0,0 +1,56 @@
+#include "horse_spearman_renderer.h"
+
+#include "../../../equipment/horse/saddles/roman_saddle_renderer.h"
+#include "../../../equipment/horse/tack/reins_renderer.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/shader.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../horse_spearman_renderer_base.h"
+
+#include <memory>
+
+namespace Render::GL::Roman {
+namespace {
+
+auto make_horse_spearman_config() -> HorseSpearmanRendererConfig {
+  HorseSpearmanRendererConfig config;
+  config.spear_equipment_id = "spear";
+  config.helmet_equipment_id = "roman_heavy";
+  config.armor_equipment_id = "roman_heavy_armor";
+  config.horse_attachments.emplace_back(
+      std::make_shared<RomanSaddleRenderer>());
+  config.horse_attachments.emplace_back(std::make_shared<ReinsRenderer>());
+  return config;
+}
+
+} // namespace
+
+void register_horse_spearman_renderer(EntityRendererRegistry &registry) {
+  registry.register_renderer(
+      "troops/roman/horse_spearman",
+      [](const DrawContext &ctx, ISubmitter &out) {
+        static HorseSpearmanRendererBase const static_renderer(
+            make_horse_spearman_config());
+        Shader *horse_spearman_shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString shader_key = static_renderer.resolve_shader_key(ctx);
+          horse_spearman_shader = ctx.backend->shader(shader_key);
+          if (horse_spearman_shader == nullptr) {
+            horse_spearman_shader =
+                ctx.backend->shader(QStringLiteral("horse_spearman"));
+          }
+        }
+        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
+        if ((scene_renderer != nullptr) &&
+            (horse_spearman_shader != nullptr)) {
+          scene_renderer->setCurrentShader(horse_spearman_shader);
+        }
+        static_renderer.render(ctx, out);
+        if (scene_renderer != nullptr) {
+          scene_renderer->setCurrentShader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Roman

+ 9 - 0
render/entity/nations/roman/horse_spearman_renderer.h

@@ -0,0 +1,9 @@
+#pragma once
+
+#include "../../registry.h"
+
+namespace Render::GL::Roman {
+
+void register_horse_spearman_renderer(EntityRendererRegistry &registry);
+
+}

+ 15 - 0
render/entity/registry.cpp

@@ -2,14 +2,20 @@
 #include "../scene_renderer.h"
 #include "barracks_renderer.h"
 #include "nations/carthage/archer_renderer.h"
+#include "nations/carthage/horse_archer_renderer.h"
+#include "nations/carthage/horse_spearman_renderer.h"
 #include "nations/carthage/horse_swordsman_renderer.h"
 #include "nations/carthage/spearman_renderer.h"
 #include "nations/carthage/swordsman_renderer.h"
 #include "nations/kingdom/archer_renderer.h"
+#include "nations/kingdom/horse_archer_renderer.h"
+#include "nations/kingdom/horse_spearman_renderer.h"
 #include "nations/kingdom/horse_swordsman_renderer.h"
 #include "nations/kingdom/spearman_renderer.h"
 #include "nations/kingdom/swordsman_renderer.h"
 #include "nations/roman/archer_renderer.h"
+#include "nations/roman/horse_archer_renderer.h"
+#include "nations/roman/horse_spearman_renderer.h"
 #include "nations/roman/horse_swordsman_renderer.h"
 #include "nations/roman/spearman_renderer.h"
 #include "nations/roman/swordsman_renderer.h"
@@ -47,6 +53,15 @@ void registerBuiltInEntityRenderers(EntityRendererRegistry &registry) {
   Kingdom::registerMountedKnightRenderer(registry);
   Roman::registerMountedKnightRenderer(registry);
   Carthage::registerMountedKnightRenderer(registry);
+
+  Kingdom::register_horse_archer_renderer(registry);
+  Roman::register_horse_archer_renderer(registry);
+  Carthage::register_horse_archer_renderer(registry);
+
+  Kingdom::register_horse_spearman_renderer(registry);
+  Roman::register_horse_spearman_renderer(registry);
+  Carthage::register_horse_spearman_renderer(registry);
+
   register_barracks_renderer(registry);
 }
 

+ 320 - 36
ui/qml/ProductionPanel.qml

@@ -22,10 +22,37 @@ Rectangle {
             "productionQueue": [],
             "product_type": "",
             "villagerCost": 1,
-            "buildTime": 0
+            "buildTime": 0,
+            "nation_id": ""
         };
     }
 
+    function unitIconSource(unitType, nationKey) {
+        if (typeof StyleGuide === "undefined" || !StyleGuide.unitIconSources || !unitType)
+            return "";
+
+        var sources = StyleGuide.unitIconSources[unitType];
+        if (!sources)
+            sources = StyleGuide.unitIconSources["default"];
+
+        if (typeof sources === "object" && sources !== null) {
+            if (nationKey && sources[nationKey])
+                return sources[nationKey];
+            if (sources["default"])
+                return sources["default"];
+        } else if (typeof sources === "string") {
+            return sources;
+        }
+
+        return "";
+    }
+
+    function unitIconEmoji(unitType) {
+        if (typeof StyleGuide !== "undefined" && StyleGuide.unitIcons)
+            return StyleGuide.unitIcons[unitType] || StyleGuide.unitIcons["default"] || "👤";
+        return "👤";
+    }
+
     color: "#0f1419"
     border.color: "#3498db"
     border.width: 2
@@ -83,6 +110,19 @@ Rectangle {
                                 property int queueTotal: (productionContent.prod.inProgress ? 1 : 0) + (productionContent.prod.queueSize || 0)
                                 property bool isOccupied: index < queueTotal
                                 property bool isProducing: index === 0 && productionContent.prod.inProgress
+                                property string queueUnitType: {
+                                    if (!isOccupied)
+                                        return "";
+
+                                    if (index === 0 && productionContent.prod.inProgress)
+                                        return productionContent.prod.product_type || "archer";
+
+                                    var queueIndex = productionContent.prod.inProgress ? index - 1 : index;
+                                    if (productionContent.prod.productionQueue && productionContent.prod.productionQueue[queueIndex])
+                                        return productionContent.prod.productionQueue[queueIndex];
+
+                                    return "archer";
+                                }
 
                                 width: 36
                                 height: 36
@@ -91,30 +131,24 @@ Rectangle {
                                 border.color: isProducing ? "#229954" : (isOccupied ? "#4a6572" : "#2a2a2a")
                                 border.width: 2
 
+                                Image {
+                                    id: queueIconImage
+                                    anchors.centerIn: parent
+                                    width: 28
+                                    height: 28
+                                    fillMode: Image.PreserveAspectFit
+                                    smooth: true
+                                    source: parent.isOccupied ? productionPanel.unitIconSource(parent.queueUnitType, productionContent.prod.nation_id) : ""
+                                    visible: parent.isOccupied && source !== ""
+                                }
+
                                 Text {
                                     anchors.centerIn: parent
-                                    text: {
-                                        if (!parent.isOccupied)
-                                            return "·";
-
-                                        var unitType;
-                                        if (index === 0 && productionContent.prod.inProgress) {
-                                            unitType = productionContent.prod.product_type || "archer";
-                                        } else {
-                                            var queueIndex = productionContent.prod.inProgress ? index - 1 : index;
-                                            if (productionContent.prod.productionQueue && productionContent.prod.productionQueue[queueIndex])
-                                                unitType = productionContent.prod.productionQueue[queueIndex];
-                                            else
-                                                unitType = "archer";
-                                        }
-                                        if (typeof StyleGuide !== 'undefined' && StyleGuide.unitIcons)
-                                            return StyleGuide.unitIcons[unitType] || StyleGuide.unitIcons["default"] || "👤";
-
-                                        return "🏹";
-                                    }
+                                    text: parent.isOccupied ? productionPanel.unitIconEmoji(parent.queueUnitType) : "·"
                                     color: parent.isProducing ? "#ffffff" : (parent.isOccupied ? "#bdc3c7" : "#3a3a3a")
                                     font.pointSize: parent.isOccupied ? 16 : 20
                                     font.bold: parent.isProducing
+                                    visible: !queueIconImage.visible
                                 }
 
                                 Text {
@@ -280,11 +314,28 @@ Rectangle {
                                 anchors.centerIn: parent
                                 spacing: 4
 
-                                Text {
+                                Item {
                                     anchors.horizontalCenter: parent.horizontalCenter
-                                    text: (typeof StyleGuide !== 'undefined' && StyleGuide.unitIcons) ? StyleGuide.unitIcons["archer"] || "🏹" : "🏹"
-                                    color: parent.parent.parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
-                                    font.pointSize: 24
+                                    width: 48
+                                    height: 48
+
+                                    Image {
+                                        id: archerRecruitIcon
+                                        anchors.fill: parent
+                                        fillMode: Image.PreserveAspectFit
+                                        smooth: true
+                                        source: productionPanel.unitIconSource("archer", unitGridContent.prod.nation_id)
+                                        visible: source !== ""
+                                        opacity: parent.parent.parent.isEnabled ? 1 : 0.4
+                                    }
+
+                                    Text {
+                                        anchors.centerIn: parent
+                                        visible: !archerRecruitIcon.visible
+                                        text: productionPanel.unitIconEmoji("archer")
+                                        color: parent.parent.parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
+                                        font.pointSize: 24
+                                    }
                                 }
 
                                 Text {
@@ -354,11 +405,28 @@ Rectangle {
                                 anchors.centerIn: parent
                                 spacing: 4
 
-                                Text {
+                                Item {
                                     anchors.horizontalCenter: parent.horizontalCenter
-                                    text: (typeof StyleGuide !== 'undefined' && StyleGuide.unitIcons) ? (StyleGuide.unitIcons["swordsman"] || StyleGuide.unitIcons["swordsman"] || "⚔️") : "⚔️"
-                                    color: parent.parent.parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
-                                    font.pointSize: 24
+                                    width: 48
+                                    height: 48
+
+                                    Image {
+                                        id: swordsmanRecruitIcon
+                                        anchors.fill: parent
+                                        fillMode: Image.PreserveAspectFit
+                                        smooth: true
+                                        source: productionPanel.unitIconSource("swordsman", unitGridContent.prod.nation_id)
+                                        visible: source !== ""
+                                        opacity: parent.parent.parent.isEnabled ? 1 : 0.4
+                                    }
+
+                                    Text {
+                                        anchors.centerIn: parent
+                                        visible: !swordsmanRecruitIcon.visible
+                                        text: productionPanel.unitIconEmoji("swordsman")
+                                        color: parent.parent.parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
+                                        font.pointSize: 24
+                                    }
                                 }
 
                                 Text {
@@ -428,11 +496,28 @@ Rectangle {
                                 anchors.centerIn: parent
                                 spacing: 4
 
-                                Text {
+                                Item {
                                     anchors.horizontalCenter: parent.horizontalCenter
-                                    text: (typeof StyleGuide !== 'undefined' && StyleGuide.unitIcons) ? StyleGuide.unitIcons["spearman"] || "🛡️" : "🛡️"
-                                    color: parent.parent.parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
-                                    font.pointSize: 24
+                                    width: 48
+                                    height: 48
+
+                                    Image {
+                                        id: spearmanRecruitIcon
+                                        anchors.fill: parent
+                                        fillMode: Image.PreserveAspectFit
+                                        smooth: true
+                                        source: productionPanel.unitIconSource("spearman", unitGridContent.prod.nation_id)
+                                        visible: source !== ""
+                                        opacity: parent.parent.parent.isEnabled ? 1 : 0.4
+                                    }
+
+                                    Text {
+                                        anchors.centerIn: parent
+                                        visible: !spearmanRecruitIcon.visible
+                                        text: productionPanel.unitIconEmoji("spearman")
+                                        color: parent.parent.parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
+                                        font.pointSize: 24
+                                    }
                                 }
 
                                 Text {
@@ -502,11 +587,28 @@ Rectangle {
                                 anchors.centerIn: parent
                                 spacing: 4
 
-                                Text {
+                                Item {
                                     anchors.horizontalCenter: parent.horizontalCenter
-                                    text: (typeof StyleGuide !== 'undefined' && StyleGuide.unitIcons) ? StyleGuide.unitIcons["horse_swordsman"] || "🐴" : "🐴"
-                                    color: parent.parent.parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
-                                    font.pointSize: 24
+                                    width: 48
+                                    height: 48
+
+                                    Image {
+                                        id: horseKnightIcon
+                                        anchors.fill: parent
+                                        fillMode: Image.PreserveAspectFit
+                                        smooth: true
+                                        source: productionPanel.unitIconSource("horse_swordsman", unitGridContent.prod.nation_id)
+                                        visible: source !== ""
+                                        opacity: parent.parent.parent.isEnabled ? 1 : 0.4
+                                    }
+
+                                    Text {
+                                        anchors.centerIn: parent
+                                        visible: !horseKnightIcon.visible
+                                        text: productionPanel.unitIconEmoji("horse_swordsman")
+                                        color: parent.parent.parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
+                                        font.pointSize: 24
+                                    }
                                 }
 
                                 Text {
@@ -560,6 +662,188 @@ Rectangle {
 
                         }
 
+                        Rectangle {
+                            property int queueTotal: (unitGridContent.prod.inProgress ? 1 : 0) + (unitGridContent.prod.queueSize || 0)
+                            property bool isEnabled: unitGridContent.prod.has_barracks && unitGridContent.prod.producedCount < unitGridContent.prod.maxUnits && queueTotal < 5
+
+                            width: 110
+                            height: 80
+                            radius: 6
+                            color: isEnabled ? (horseArcherMouseArea.containsMouse ? "#34495e" : "#2c3e50") : "#1a1a1a"
+                            border.color: isEnabled ? "#4a6572" : "#2a2a2a"
+                            border.width: 2
+                            opacity: isEnabled ? 1 : 0.5
+
+                            Column {
+                                anchors.centerIn: parent
+                                spacing: 4
+
+                                Item {
+                                    anchors.horizontalCenter: parent.horizontalCenter
+                                    width: 48
+                                    height: 48
+
+                                    Image {
+                                        id: horseArcherIcon
+                                        anchors.fill: parent
+                                        fillMode: Image.PreserveAspectFit
+                                        smooth: true
+                                        source: productionPanel.unitIconSource("horse_archer", unitGridContent.prod.nation_id)
+                                        visible: source !== ""
+                                        opacity: parent.parent.parent.isEnabled ? 1 : 0.4
+                                    }
+
+                                    Text {
+                                        anchors.centerIn: parent
+                                        visible: !horseArcherIcon.visible
+                                        text: productionPanel.unitIconEmoji("horse_archer")
+                                        color: parent.parent.parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
+                                        font.pointSize: 20
+                                    }
+                                }
+
+                                Text {
+                                    anchors.horizontalCenter: parent.horizontalCenter
+                                    text: qsTr("Horse Archer")
+                                    color: parent.parent.parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
+                                    font.pointSize: 9
+                                    font.bold: true
+                                }
+
+                                Row {
+                                    anchors.horizontalCenter: parent.horizontalCenter
+                                    spacing: 4
+
+                                    Text {
+                                        text: "👥"
+                                        color: parent.parent.parent.parent.isEnabled ? "#f39c12" : "#5a5a5a"
+                                        font.pointSize: 9
+                                    }
+
+                                    Text {
+                                        text: unitGridContent.prod.villagerCost || 1
+                                        color: parent.parent.parent.parent.isEnabled ? "#f39c12" : "#5a5a5a"
+                                        font.pointSize: 9
+                                        font.bold: true
+                                    }
+
+                                }
+
+                            }
+
+                            MouseArea {
+                                id: horseArcherMouseArea
+
+                                anchors.fill: parent
+                                hoverEnabled: true
+                                enabled: parent.isEnabled
+                                onClicked: productionPanel.recruitUnit("horse_archer")
+                                cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
+                                ToolTip.visible: containsMouse
+                                ToolTip.text: parent.isEnabled ? qsTr("Recruit Horse Archer\nCost: %1 villagers\nBuild time: %2s").arg(unitGridContent.prod.villagerCost || 1).arg((unitGridContent.prod.buildTime || 0).toFixed(0)) : (parent.queueTotal >= 5 ? qsTr("Queue is full (5/5)") : (unitGridContent.prod.producedCount >= unitGridContent.prod.maxUnits ? qsTr("Unit cap reached") : qsTr("Cannot recruit")))
+                                ToolTip.delay: 300
+                            }
+
+                            Rectangle {
+                                anchors.fill: parent
+                                color: "#ffffff"
+                                opacity: horseArcherMouseArea.pressed ? 0.2 : 0
+                                radius: parent.radius
+                            }
+
+                        }
+
+                        Rectangle {
+                            property int queueTotal: (unitGridContent.prod.inProgress ? 1 : 0) + (unitGridContent.prod.queueSize || 0)
+                            property bool isEnabled: unitGridContent.prod.has_barracks && unitGridContent.prod.producedCount < unitGridContent.prod.maxUnits && queueTotal < 5
+
+                            width: 110
+                            height: 80
+                            radius: 6
+                            color: isEnabled ? (horseSpearmanMouseArea.containsMouse ? "#34495e" : "#2c3e50") : "#1a1a1a"
+                            border.color: isEnabled ? "#4a6572" : "#2a2a2a"
+                            border.width: 2
+                            opacity: isEnabled ? 1 : 0.5
+
+                            Column {
+                                anchors.centerIn: parent
+                                spacing: 4
+
+                                Item {
+                                    anchors.horizontalCenter: parent.horizontalCenter
+                                    width: 48
+                                    height: 48
+
+                                    Image {
+                                        id: horseSpearmanIcon
+                                        anchors.fill: parent
+                                        fillMode: Image.PreserveAspectFit
+                                        smooth: true
+                                        source: productionPanel.unitIconSource("horse_spearman", unitGridContent.prod.nation_id)
+                                        visible: source !== ""
+                                        opacity: parent.parent.parent.isEnabled ? 1 : 0.4
+                                    }
+
+                                    Text {
+                                        anchors.centerIn: parent
+                                        visible: !horseSpearmanIcon.visible
+                                        text: productionPanel.unitIconEmoji("horse_spearman")
+                                        color: parent.parent.parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
+                                        font.pointSize: 20
+                                    }
+                                }
+
+                                Text {
+                                    anchors.horizontalCenter: parent.horizontalCenter
+                                    text: qsTr("Horse Spearman")
+                                    color: parent.parent.parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
+                                    font.pointSize: 9
+                                    font.bold: true
+                                }
+
+                                Row {
+                                    anchors.horizontalCenter: parent.horizontalCenter
+                                    spacing: 4
+
+                                    Text {
+                                        text: "👥"
+                                        color: parent.parent.parent.parent.isEnabled ? "#f39c12" : "#5a5a5a"
+                                        font.pointSize: 9
+                                    }
+
+                                    Text {
+                                        text: unitGridContent.prod.villagerCost || 1
+                                        color: parent.parent.parent.parent.isEnabled ? "#f39c12" : "#5a5a5a"
+                                        font.pointSize: 9
+                                        font.bold: true
+                                    }
+
+                                }
+
+                            }
+
+                            MouseArea {
+                                id: horseSpearmanMouseArea
+
+                                anchors.fill: parent
+                                hoverEnabled: true
+                                enabled: parent.isEnabled
+                                onClicked: productionPanel.recruitUnit("horse_spearman")
+                                cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
+                                ToolTip.visible: containsMouse
+                                ToolTip.text: parent.isEnabled ? qsTr("Recruit Horse Spearman\nCost: %1 villagers\nBuild time: %2s").arg(unitGridContent.prod.villagerCost || 1).arg((unitGridContent.prod.buildTime || 0).toFixed(0)) : (parent.queueTotal >= 5 ? qsTr("Queue is full (5/5)") : (unitGridContent.prod.producedCount >= unitGridContent.prod.maxUnits ? qsTr("Unit cap reached") : qsTr("Cannot recruit")))
+                                ToolTip.delay: 300
+                            }
+
+                            Rectangle {
+                                anchors.fill: parent
+                                color: "#ffffff"
+                                opacity: horseSpearmanMouseArea.pressed ? 0.2 : 0
+                                radius: parent.radius
+                            }
+
+                        }
+
                     }
 
                 }

+ 65 - 2
ui/qml/StyleGuide.qml

@@ -126,9 +126,72 @@ QtObject {
     readonly property var unitIcons: ({
         "archer": "🏹",
         "swordsman": "⚔️",
-        "swordsman": "⚔️",
         "spearman": "🛡️",
-        "horse_swordsman": "🐴",
+        "horse_swordsman": "🐎⚔️",
+        "horse_archer": "🏹🐎",
+        "horse_spearman": "🐎🛡️",
+        "healer": "✚",
+        "catapult": "🛞",
+        "ballista": "🎯",
         "default": "👤"
     })
+    readonly property var unitIconSources: ({
+        "archer": ({
+            "default": "qrc:/assets/visuals/icons/archer_rome.png",
+            "kingdom_of_iron": "qrc:/assets/visuals/icons/archer_rome.png",
+            "roman_republic": "qrc:/assets/visuals/icons/archer_rome.png",
+            "carthage": "qrc:/assets/visuals/icons/archer_cartaghe.png"
+        }),
+        "swordsman": ({
+            "default": "qrc:/assets/visuals/icons/swordsman_rome.png",
+            "kingdom_of_iron": "qrc:/assets/visuals/icons/swordsman_rome.png",
+            "roman_republic": "qrc:/assets/visuals/icons/swordsman_rome.png",
+            "carthage": "qrc:/assets/visuals/icons/swordsman_cartaghe.png"
+        }),
+        "spearman": ({
+            "default": "qrc:/assets/visuals/icons/spearman_rome.png",
+            "kingdom_of_iron": "qrc:/assets/visuals/icons/spearman_rome.png",
+            "roman_republic": "qrc:/assets/visuals/icons/spearman_rome.png",
+            "carthage": "qrc:/assets/visuals/icons/spearman_cartaghe.png"
+        }),
+        "horse_swordsman": ({
+            "default": "qrc:/assets/visuals/icons/horse_swordsman_rome.png",
+            "kingdom_of_iron": "qrc:/assets/visuals/icons/horse_swordsman_rome.png",
+            "roman_republic": "qrc:/assets/visuals/icons/horse_swordsman_rome.png",
+            "carthage": "qrc:/assets/visuals/icons/horse_swordsman_cartaghe.png"
+        }),
+        "horse_archer": ({
+            "default": "qrc:/assets/visuals/icons/horse_archer_rome.png",
+            "kingdom_of_iron": "qrc:/assets/visuals/icons/horse_archer_rome.png",
+            "roman_republic": "qrc:/assets/visuals/icons/horse_archer_rome.png",
+            "carthage": "qrc:/assets/visuals/icons/horse_archer_cartaghe.png"
+        }),
+        "horse_spearman": ({
+            "default": "qrc:/assets/visuals/icons/horse_spearman_rome.png",
+            "kingdom_of_iron": "qrc:/assets/visuals/icons/horse_spearman_rome.png",
+            "roman_republic": "qrc:/assets/visuals/icons/horse_spearman_rome.png",
+            "carthage": "qrc:/assets/visuals/icons/horse_spearman_cartaghe.png"
+        }),
+        "healer": ({
+            "default": "qrc:/assets/visuals/icons/healer_rome.png",
+            "kingdom_of_iron": "qrc:/assets/visuals/icons/healer_rome.png",
+            "roman_republic": "qrc:/assets/visuals/icons/healer_rome.png",
+            "carthage": "qrc:/assets/visuals/icons/healer_cartaghe.png"
+        }),
+        "catapult": ({
+            "default": "qrc:/assets/visuals/icons/catapult_rome.png",
+            "kingdom_of_iron": "qrc:/assets/visuals/icons/catapult_rome.png",
+            "roman_republic": "qrc:/assets/visuals/icons/catapult_rome.png",
+            "carthage": "qrc:/assets/visuals/icons/catapult_cartaghe.png"
+        }),
+        "ballista": ({
+            "default": "qrc:/assets/visuals/icons/ballista_rome.png",
+            "kingdom_of_iron": "qrc:/assets/visuals/icons/ballista_rome.png",
+            "roman_republic": "qrc:/assets/visuals/icons/ballista_rome.png",
+            "carthage": "qrc:/assets/visuals/icons/ballista_cartaghe.png"
+        }),
+        "default": ({
+            "default": ""
+        })
+    })
 }