Browse Source

Merge pull request #595 from djeada/copilot/add-new-troop-builder

Add new troop/unit: builder for both factions
Adam Djellouli 1 day ago
parent
commit
2205472f83
51 changed files with 2859 additions and 173 deletions
  1. 8 1
      CMakeLists.txt
  2. 93 0
      app/core/game_engine.cpp
  3. 3 0
      app/core/game_engine.h
  4. 16 0
      assets.qrc
  5. 35 0
      assets/data/nations/carthage.json
  6. 35 0
      assets/data/nations/roman_republic.json
  7. 35 0
      assets/data/troops/base.json
  8. 83 0
      assets/shaders/builder.frag
  9. 19 0
      assets/shaders/builder.vert
  10. 272 0
      assets/shaders/builder_carthage.frag
  11. 72 0
      assets/shaders/builder_carthage.vert
  12. 279 0
      assets/shaders/builder_roman_republic.frag
  13. 65 0
      assets/shaders/builder_roman_republic.vert
  14. BIN
      assets/visuals/icons/builder_cartaghe.png
  15. BIN
      assets/visuals/icons/builder_rome.png
  16. BIN
      assets/visuals/icons/defense_tower_cartaghe.png
  17. BIN
      assets/visuals/icons/defense_tower_rome.png
  18. BIN
      assets/visuals/icons/healer_rom e.png
  19. BIN
      assets/visuals/icons/house_cartaghe.png
  20. BIN
      assets/visuals/icons/house_rome.png
  21. BIN
      assets/visuals/icons/wall_cartaghe.png
  22. BIN
      assets/visuals/icons/wall_rome.png
  23. 1 0
      game/CMakeLists.txt
  24. 11 0
      game/core/component.h
  25. 56 0
      game/systems/production_system.cpp
  26. 111 0
      game/units/builder.cpp
  27. 17 0
      game/units/builder.h
  28. 6 0
      game/units/factory.cpp
  29. 18 2
      game/units/spawn_type.h
  30. 32 0
      game/units/troop_catalog.cpp
  31. 8 1
      game/units/troop_type.h
  32. 38 0
      qml_resources.qrc
  33. 4 0
      render/CMakeLists.txt
  34. 40 0
      render/entity/combat_dust_renderer.cpp
  35. 461 0
      render/entity/nations/carthage/builder_renderer.cpp
  36. 15 0
      render/entity/nations/carthage/builder_renderer.h
  37. 45 0
      render/entity/nations/carthage/builder_style.cpp
  38. 30 0
      render/entity/nations/carthage/builder_style.h
  39. 429 0
      render/entity/nations/roman/builder_renderer.cpp
  40. 15 0
      render/entity/nations/roman/builder_renderer.h
  41. 40 0
      render/entity/nations/roman/builder_style.cpp
  42. 27 0
      render/entity/nations/roman/builder_style.h
  43. 5 0
      render/entity/registry.cpp
  44. 10 0
      render/gl/humanoid/animation/animation_inputs.cpp
  45. 2 0
      render/gl/humanoid/humanoid_types.h
  46. 35 0
      render/humanoid/formation_calculator.cpp
  47. 13 1
      render/humanoid/formation_calculator.h
  48. 5 0
      ui/qml/HUDBottom.qml
  49. 1 1
      ui/qml/LoadScreen.qml
  50. 344 166
      ui/qml/ProductionPanel.qml
  51. 25 1
      ui/qml/StyleGuide.qml

+ 8 - 1
CMakeLists.txt

@@ -295,6 +295,14 @@ if(QT_VERSION_MAJOR EQUAL 6)
             assets/visuals/icons/catapult_cartaghe.png
             assets/visuals/icons/catapult_cartaghe.png
             assets/visuals/icons/ballista_rome.png
             assets/visuals/icons/ballista_rome.png
             assets/visuals/icons/ballista_cartaghe.png
             assets/visuals/icons/ballista_cartaghe.png
+            assets/visuals/icons/builder_rome.png
+            assets/visuals/icons/builder_cartaghe.png
+            assets/visuals/icons/defense_tower_rome.png
+            assets/visuals/icons/defense_tower_cartaghe.png
+            assets/visuals/icons/wall_rome.png
+            assets/visuals/icons/wall_cartaghe.png
+            assets/visuals/icons/house_rome.png
+            assets/visuals/icons/house_cartaghe.png
             translations/app_en.qm
             translations/app_en.qm
             translations/app_de.qm
             translations/app_de.qm
             translations/app_pt_br.qm
             translations/app_pt_br.qm
@@ -396,4 +404,3 @@ add_custom_target(validate-content
 # Optionally make validation run as part of the build
 # Optionally make validation run as part of the build
 # Uncomment the following line to make build fail on invalid content:
 # Uncomment the following line to make build fail on invalid content:
 # add_dependencies(standard_of_iron validate-content)
 # add_dependencies(standard_of_iron validate-content)
-

+ 93 - 0
app/core/game_engine.cpp

@@ -1093,6 +1093,99 @@ auto GameEngine::get_unit_production_info(const QString &unit_type) const
   return info;
   return info;
 }
 }
 
 
+auto GameEngine::get_selected_builder_production_state() const -> QVariantMap {
+  QVariantMap m;
+  m["in_progress"] = false;
+  m["time_remaining"] = 0.0;
+  m["build_time"] = 10.0;
+  m["product_type"] = "";
+
+  if (!m_world) {
+    return m;
+  }
+
+  auto *selection_system =
+      m_world->get_system<Game::Systems::SelectionSystem>();
+  if (selection_system == nullptr) {
+    return m;
+  }
+
+  const auto &selected = selection_system->get_selected_units();
+  for (auto id : selected) {
+    auto *e = m_world->get_entity(id);
+    if (e == nullptr) {
+      continue;
+    }
+
+    auto *builder_prod =
+        e->get_component<Engine::Core::BuilderProductionComponent>();
+    if (builder_prod == nullptr) {
+      continue;
+    }
+
+    m["in_progress"] = builder_prod->in_progress;
+    m["time_remaining"] = builder_prod->time_remaining;
+    m["build_time"] = builder_prod->build_time;
+    m["product_type"] = QString::fromStdString(builder_prod->product_type);
+    return m;
+  }
+
+  return m;
+}
+
+void GameEngine::start_builder_construction(const QString &item_type) {
+  if (!m_world) {
+    return;
+  }
+
+  auto *selection_system =
+      m_world->get_system<Game::Systems::SelectionSystem>();
+  if (selection_system == nullptr) {
+    return;
+  }
+
+  const auto &selected = selection_system->get_selected_units();
+  for (auto id : selected) {
+    auto *e = m_world->get_entity(id);
+    if (e == nullptr) {
+      continue;
+    }
+
+    auto *builder_prod =
+        e->get_component<Engine::Core::BuilderProductionComponent>();
+    if (builder_prod == nullptr) {
+      continue;
+    }
+
+    if (builder_prod == nullptr) {
+      builder_prod =
+          e->add_component<Engine::Core::BuilderProductionComponent>();
+    }
+
+    if (builder_prod->in_progress) {
+      continue;
+    }
+
+    std::string item_str = item_type.toStdString();
+    builder_prod->product_type = item_str;
+    builder_prod->in_progress = true;
+    builder_prod->construction_complete = false;
+
+    if (item_str == "catapult") {
+      builder_prod->build_time = 15.0f;
+    } else if (item_str == "ballista") {
+      builder_prod->build_time = 12.0f;
+    } else if (item_str == "defense_tower") {
+      builder_prod->build_time = 20.0f;
+    } else {
+      builder_prod->build_time = 10.0f;
+    }
+    builder_prod->time_remaining = builder_prod->build_time;
+
+    return;
+  }
+}
+
 auto GameEngine::get_selected_units_command_mode() const -> QString {
 auto GameEngine::get_selected_units_command_mode() const -> QString {
   if (!m_world) {
   if (!m_world) {
     return "normal";
     return "normal";

+ 3 - 0
app/core/game_engine.h

@@ -233,6 +233,9 @@ public:
   Q_INVOKABLE [[nodiscard]] QString pending_building_type() const;
   Q_INVOKABLE [[nodiscard]] QString pending_building_type() const;
   Q_INVOKABLE [[nodiscard]] QVariantMap get_selected_production_state() const;
   Q_INVOKABLE [[nodiscard]] QVariantMap get_selected_production_state() const;
   Q_INVOKABLE [[nodiscard]] QVariantMap
   Q_INVOKABLE [[nodiscard]] QVariantMap
+  get_selected_builder_production_state() const;
+  Q_INVOKABLE void start_builder_construction(const QString &item_type);
+  Q_INVOKABLE [[nodiscard]] QVariantMap
   get_unit_production_info(const QString &unit_type) const;
   get_unit_production_info(const QString &unit_type) const;
   Q_INVOKABLE [[nodiscard]] QString get_selected_units_command_mode() const;
   Q_INVOKABLE [[nodiscard]] QString get_selected_units_command_mode() const;
   Q_INVOKABLE [[nodiscard]] QVariantMap
   Q_INVOKABLE [[nodiscard]] QVariantMap

+ 16 - 0
assets.qrc

@@ -48,6 +48,12 @@
         <file>assets/shaders/healer_roman_republic.vert</file>
         <file>assets/shaders/healer_roman_republic.vert</file>
         <file>assets/shaders/healer_carthage.frag</file>
         <file>assets/shaders/healer_carthage.frag</file>
         <file>assets/shaders/healer_carthage.vert</file>
         <file>assets/shaders/healer_carthage.vert</file>
+        <file>assets/shaders/builder.frag</file>
+        <file>assets/shaders/builder.vert</file>
+        <file>assets/shaders/builder_roman_republic.frag</file>
+        <file>assets/shaders/builder_roman_republic.vert</file>
+        <file>assets/shaders/builder_carthage.frag</file>
+        <file>assets/shaders/builder_carthage.vert</file>
         <file>assets/shaders/pine_instanced.frag</file>
         <file>assets/shaders/pine_instanced.frag</file>
         <file>assets/shaders/pine_instanced.vert</file>
         <file>assets/shaders/pine_instanced.vert</file>
         <file>assets/shaders/olive_instanced.frag</file>
         <file>assets/shaders/olive_instanced.frag</file>
@@ -117,6 +123,16 @@
         <file>assets/visuals/icons/catapult_cartaghe.png</file>
         <file>assets/visuals/icons/catapult_cartaghe.png</file>
         <file>assets/visuals/icons/ballista_rome.png</file>
         <file>assets/visuals/icons/ballista_rome.png</file>
         <file>assets/visuals/icons/ballista_cartaghe.png</file>
         <file>assets/visuals/icons/ballista_cartaghe.png</file>
+        <file>assets/visuals/icons/builder_rome.png</file>
+        <file>assets/visuals/icons/builder_cartaghe.png</file>
+        <file>assets/visuals/icons/defense_tower_rome.png</file>
+        <file>assets/visuals/icons/defense_tower_cartaghe.png</file>
+        <file>assets/visuals/icons/wall_rome.png</file>
+        <file>assets/visuals/icons/wall_cartaghe.png</file>
+        <file>assets/visuals/icons/house_rome.png</file>
+        <file>assets/visuals/icons/house_cartaghe.png</file>
+        <file>assets/visuals/icons/defense_tower_rome.png</file>
+        <file>assets/visuals/icons/defense_tower_cartaghe.png</file>
 
 
         <!-- Gameplay data -->
         <!-- Gameplay data -->
         <file>assets/data/troops/base.json</file>
         <file>assets/data/troops/base.json</file>

+ 35 - 0
assets/data/nations/carthage.json

@@ -245,6 +245,41 @@
         "individuals_per_unit": 9,
         "individuals_per_unit": 9,
         "max_units_per_row": 3
         "max_units_per_row": 3
       }
       }
+    },
+    {
+      "id": "builder",
+      "display_name": "Phoenician Craftsman",
+      "production": {
+        "cost": 55,
+        "build_time": 5.8,
+        "priority": 4,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 675,
+        "max_health": 675,
+        "speed": 2.2,
+        "vision_range": 10.0,
+        "ranged_range": 1.5,
+        "ranged_damage": 2,
+        "ranged_cooldown": 2.0,
+        "melee_range": 1.5,
+        "melee_damage": 5,
+        "melee_cooldown": 1.0,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.50,
+        "selection_ring_size": 1.0,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/carthage/builder"
+      },
+      "formation": {
+        "individuals_per_unit": 12,
+        "max_units_per_row": 4
+      }
     }
     }
   ]
   ]
 }
 }

+ 35 - 0
assets/data/nations/roman_republic.json

@@ -245,6 +245,41 @@
         "individuals_per_unit": 9,
         "individuals_per_unit": 9,
         "max_units_per_row": 3
         "max_units_per_row": 3
       }
       }
+    },
+    {
+      "id": "builder",
+      "display_name": "Faber",
+      "production": {
+        "cost": 65,
+        "build_time": 6.2,
+        "priority": 4,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 765,
+        "max_health": 765,
+        "speed": 2.1,
+        "vision_range": 10.5,
+        "ranged_range": 1.5,
+        "ranged_damage": 2,
+        "ranged_cooldown": 2.0,
+        "melee_range": 1.5,
+        "melee_damage": 6,
+        "melee_cooldown": 0.95,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.50,
+        "selection_ring_size": 1.0,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/roman/builder"
+      },
+      "formation": {
+        "individuals_per_unit": 12,
+        "max_units_per_row": 4
+      }
     }
     }
   ]
   ]
 }
 }

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

@@ -311,6 +311,41 @@
         "individuals_per_unit": 1,
         "individuals_per_unit": 1,
         "max_units_per_row": 1
         "max_units_per_row": 1
       }
       }
+    },
+    {
+      "id": "builder",
+      "display_name": "Builder",
+      "production": {
+        "cost": 60,
+        "build_time": 6.0,
+        "priority": 4,
+        "is_melee": true
+      },
+      "combat": {
+        "health": 720,
+        "max_health": 720,
+        "speed": 2.0,
+        "vision_range": 10.0,
+        "ranged_range": 1.5,
+        "ranged_damage": 2,
+        "ranged_cooldown": 2.0,
+        "melee_range": 1.5,
+        "melee_damage": 5,
+        "melee_cooldown": 1.0,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.50,
+        "selection_ring_size": 1.0,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/roman/builder"
+      },
+      "formation": {
+        "individuals_per_unit": 12,
+        "max_units_per_row": 4
+      }
     }
     }
   ]
   ]
 }
 }

+ 83 - 0
assets/shaders/builder.frag

@@ -0,0 +1,83 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+
+out vec4 FragColor;
+
+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 clothWeave(vec2 p) {
+  float warpThread = sin(p.x * 70.0);
+  float weftThread = sin(p.y * 68.0);
+  return warpThread * weftThread * 0.06;
+}
+
+float leatherGrain(vec2 p) {
+  return noise(p * 16.0) * 0.12 + noise(p * 28.0) * 0.06;
+}
+
+void main() {
+  vec3 color = u_color;
+  if (u_useTexture) {
+    color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  vec3 normal = normalize(v_normal);
+  vec2 uv = v_worldPos.xz * 4.5;
+  float avgColor = (color.r + color.g + color.b) / 3.0;
+
+  if (avgColor > 0.65) {
+    float weave = clothWeave(v_worldPos.xz);
+    float folds = noise(uv * 8.0) * 0.13;
+
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.2))));
+    float clothSheen = pow(1.0 - viewAngle, 9.0) * 0.12;
+
+    color *= 1.0 + weave + folds - 0.03;
+    color += vec3(clothSheen);
+  }
+
+  else if (avgColor > 0.30) {
+    float leather = leatherGrain(uv);
+    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.4))));
+    float leatherSheen = pow(1.0 - viewAngle, 6.0) * 0.10;
+
+    color *= 1.0 + leather - 0.04;
+    color += vec3(leatherSheen);
+  } else {
+    float detail = noise(uv * 10.0) * 0.10;
+    color *= 1.0 + detail - 0.06;
+  }
+
+  color = clamp(color, 0.0, 1.0);
+
+  vec3 lightDir = normalize(vec3(1.0, 1.2, 1.0));
+  float nDotL = dot(normal, lightDir);
+  float wrapAmount = 0.45;
+  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.28);
+
+  color *= diff;
+  FragColor = vec4(color, u_alpha);
+}

+ 19 - 0
assets/shaders/builder.vert

@@ -0,0 +1,19 @@
+#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;
+
+void main() {
+  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 272 - 0
assets/shaders/builder_carthage.frag

@@ -0,0 +1,272 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec3 v_worldNormal;
+in vec3 v_tangent;
+in vec3 v_bitangent;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+in float v_armorLayer;
+in float v_bodyHeight;
+in float v_clothFolds;
+in float v_fabricWear;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+uniform int u_materialId;
+
+out vec4 FragColor;
+
+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 sum = 0.0;
+  float amp = 0.5;
+  float freq = 1.0;
+  for (int i = 0; i < 4; ++i) {
+    sum += amp * noise(p * freq);
+    freq *= 2.12;
+    amp *= 0.48;
+  }
+  return sum;
+}
+
+float triplanar_noise(vec3 pos, vec3 normal, float scale) {
+  vec3 w = abs(normal);
+  w = max(w, vec3(0.0001));
+  w /= (w.x + w.y + w.z);
+  float xy = noise(pos.xy * scale);
+  float yz = noise(pos.yz * scale);
+  float zx = noise(pos.zx * scale);
+  return xy * w.z + yz * w.x + zx * w.y;
+}
+
+float cloth_weave(vec2 p) {
+  float warp = sin(p.x * 68.0) * 0.55 + sin(p.x * 132.0) * 0.20;
+  float weft = sin(p.y * 66.0) * 0.55 + sin(p.y * 124.0) * 0.20;
+  float cross = sin(p.x * 12.0 + p.y * 14.0) * 0.08;
+  return warp * weft * 0.06 + cross * 0.04;
+}
+
+float phoenician_linen(vec2 p) {
+  float weave = cloth_weave(p);
+  float slub = fbm(p * 8.5) * 0.08;
+  float fine_thread = noise(p * 90.0) * 0.08;
+  float sun_kiss = noise(p * 2.8) * 0.04;
+  return weave + slub + fine_thread + sun_kiss;
+}
+
+vec3 perturb_cloth_normal(vec3 N, vec3 T, vec3 B, vec2 uv, float warpFreq,
+                          float weftFreq, float slubStrength) {
+  float warp = sin(uv.x * warpFreq) * 0.06;
+  float weft = sin(uv.y * weftFreq) * 0.06;
+  float slub = fbm(uv * 7.0) * slubStrength;
+  return normalize(N + T * (warp + slub) + B * (weft + slub * 0.6));
+}
+
+vec3 perturb_leather_normal(vec3 N, vec3 T, vec3 B, vec2 uv) {
+  float grain = fbm(uv * 8.0) * 0.18;
+  float pores = noise(uv * 32.0) * 0.10;
+  float scars = noise(uv * 14.0 + vec2(3.7, -2.1)) * 0.06;
+  return normalize(N + T * (grain + scars * 0.4) + B * (pores + scars * 0.3));
+}
+
+vec3 perturb_bronze_normal(vec3 N, vec3 T, vec3 B, vec2 uv) {
+  float hammer = fbm(uv * 14.0) * 0.15;
+  float ripple = noise(uv * 48.0) * 0.05;
+  return normalize(N + T * hammer + B * (hammer * 0.4 + ripple));
+}
+
+float D_GGX(float NdotH, float a) {
+  float a2 = a * a;
+  float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
+  return a2 / max(3.14159 * d * d, 1e-5);
+}
+
+float G_Smith(float NdotV, float NdotL, float a) {
+  float k = (a + 1.0);
+  k = (k * k) / 8.0;
+  float g_v = NdotV / (NdotV * (1.0 - k) + k);
+  float g_l = NdotL / (NdotL * (1.0 - k) + k);
+  return g_v * g_l;
+}
+
+vec3 fresnel_schlick(vec3 F0, float cos_theta) {
+  return F0 + (vec3(1.0) - F0) * pow(1.0 - cos_theta, 5.0);
+}
+
+vec3 compute_ambient(vec3 normal) {
+  float up = clamp(normal.y, 0.0, 1.0);
+  float down = clamp(-normal.y, 0.0, 1.0);
+  vec3 sky = vec3(0.85, 0.92, 1.0);
+  vec3 ground = vec3(0.45, 0.38, 0.32);
+  return sky * (0.40 + 0.50 * up) + ground * (0.20 + 0.40 * down);
+}
+
+vec3 apply_lighting(vec3 albedo, vec3 N, vec3 V, vec3 L, float roughness,
+                    float metallic, float ao, float sheen, float wrap) {
+  float NdotL = max(dot(N, L), 0.0);
+  float NdotV = max(dot(N, V), 0.0);
+  float wrapped = clamp(NdotL * (1.0 - wrap) + wrap, 0.0, 1.0);
+  NdotL = wrapped;
+
+  vec3 H = normalize(L + V);
+  float NdotH = max(dot(N, H), 0.0);
+  float VdotH = max(dot(V, H), 0.0);
+
+  float a = max(roughness * roughness, 0.03);
+  float D = D_GGX(NdotH, a);
+  float G = G_Smith(NdotV, NdotL, a);
+
+  vec3 F0 = mix(vec3(0.04), albedo, metallic);
+  vec3 F = fresnel_schlick(F0, VdotH);
+
+  vec3 spec = (D * G * F) / max(4.0 * NdotV * NdotL + 1e-5, 1e-5);
+  vec3 kd = (vec3(1.0) - F) * (1.0 - metallic);
+  vec3 diffuse = kd * albedo / 3.14159;
+
+  vec3 ambient = compute_ambient(N) * albedo;
+  vec3 light = (diffuse + spec * (1.0 + sheen)) * NdotL;
+
+  float ao_strength = mix(0.45, 1.0, clamp(ao, 0.0, 1.0));
+  vec3 result = ambient * (0.80 + 0.40 * ao_strength) +
+                light * (0.80 * ao_strength + 0.20);
+  return result;
+}
+
+void main() {
+  vec3 N = normalize(v_worldNormal);
+  vec3 T = normalize(v_tangent);
+  vec3 B = normalize(v_bitangent);
+  vec2 uv = v_worldPos.xz * 4.5;
+
+  vec3 base_color = clamp(u_color, 0.0, 1.0);
+  if (u_useTexture) {
+    base_color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  vec3 teamDefault = vec3(0.0);
+  vec3 teamColor = clamp(mix(teamDefault, u_color, 0.75), 0.0, 1.0);
+
+  bool is_body = (u_materialId == 0);
+  bool is_tunic = (u_materialId == 1);
+  bool is_leather = (u_materialId == 2);
+  bool is_tools = (u_materialId == 3);
+
+  vec3 V = normalize(vec3(0.0, 1.4, 3.0) - v_worldPos);
+  vec3 L = normalize(vec3(2.0, 3.0, 1.5));
+
+  float curvature = length(dFdx(N)) + length(dFdy(N));
+  float ao_folds =
+      clamp(1.0 - (v_clothFolds * 0.55 + curvature * 0.80), 0.35, 1.0);
+  float dust_mask = smoothstep(0.22, 0.0, v_bodyHeight);
+  float sun_bleach = smoothstep(0.55, 1.05, v_bodyHeight) * 0.09;
+
+  vec3 albedo = base_color;
+  vec3 N_used = N;
+  float roughness = 0.55;
+  float metallic = 0.0;
+  float sheen = 0.0;
+  float wrap = 0.44;
+  float ao = ao_folds;
+
+  if (is_body) {
+    vec3 skin_base = vec3(0.08, 0.07, 0.065);
+    float legs = smoothstep(0.05, 0.50, v_bodyHeight) *
+                 (1.0 - smoothstep(0.52, 0.70, v_bodyHeight));
+    float limb_team = clamp(legs, 0.0, 1.0);
+    skin_base = mix(skin_base, mix(skin_base, teamColor, 0.92), limb_team);
+    float tone_noise = fbm(v_worldPos.xz * 3.1) - 0.5;
+    albedo = clamp(skin_base + vec3(tone_noise) * 0.04, 0.0, 1.0);
+    float skin_detail = noise(uv * 24.0) * 0.06;
+    float subdermal = noise(uv * 7.0) * 0.05;
+    N_used = normalize(N + vec3(0.0, 0.01, 0.0) *
+                               triplanar_noise(v_worldPos * 3.0, N, 5.5));
+    albedo *= 1.0 + skin_detail;
+    albedo += vec3(0.03, 0.015, 0.0) * subdermal;
+    albedo *= 1.18;
+    float rim = pow(1.0 - clamp(dot(N_used, V), 0.0, 1.0), 4.0) * 0.08;
+    albedo += vec3(rim);
+    sheen = 0.08 + subdermal * 0.2;
+    wrap = 0.46;
+  } else if (is_tunic) {
+    float linen = phoenician_linen(uv);
+    float weave = cloth_weave(uv);
+    float drape_folds = v_clothFolds * noise(uv * 9.0) * 0.18;
+    float dust = dust_mask * (0.12 + noise(uv * 7.0) * 0.12);
+    N_used = perturb_cloth_normal(N, T, B, uv, 128.0, 116.0, 0.08);
+    vec3 tunic_base = vec3(0.68, 0.54, 0.38);
+    albedo = tunic_base;
+    albedo *= 1.02 + linen + weave * 0.08 - drape_folds * 0.5;
+    albedo += vec3(0.04, 0.03, 0.0) * sun_bleach;
+    albedo -= vec3(dust * 0.20);
+    roughness = 0.66 - clamp(v_fabricWear * 0.08, 0.0, 0.12);
+    sheen = 0.10 + clamp(v_bodyHeight * 0.04, 0.0, 0.06);
+    ao *= 1.0 - dust * 0.30;
+    wrap = 0.54;
+  } else if (is_leather) {
+    float leather_grain = fbm(uv * 8.0) * 0.16;
+    float craft_detail = noise(uv * 28.0) * 0.07;
+    float stitching = step(0.92, fract(v_worldPos.x * 14.0)) *
+                      step(0.92, fract(v_worldPos.y * 12.0)) * 0.08;
+    float edge_wear =
+        smoothstep(0.86, 0.94, abs(dot(N, normalize(T + B)))) * 0.08;
+    N_used = perturb_leather_normal(N, T, B, uv);
+    vec3 leather_base = vec3(0.44, 0.30, 0.18);
+    albedo = leather_base;
+    float belt_band = smoothstep(0.47, 0.49, v_bodyHeight) -
+                      smoothstep(0.53, 0.55, v_bodyHeight);
+    albedo *= 1.06 + leather_grain + craft_detail - 0.04;
+    albedo += vec3(stitching + edge_wear);
+    albedo = mix(albedo, mix(albedo, teamColor, 0.80), belt_band);
+    roughness = 0.52 - clamp(v_fabricWear * 0.05, 0.0, 0.10);
+    sheen = 0.11;
+    wrap = 0.46;
+  } else if (is_tools) {
+    float patina = noise(uv * 14.0) * 0.15 + fbm(uv * 22.0) * 0.10;
+    float edge_polish =
+        smoothstep(0.86, 0.95, abs(dot(N, normalize(T + B)))) * 0.14;
+    N_used = perturb_bronze_normal(N, T, B, uv);
+    vec3 bronze_default = vec3(0.78, 0.58, 0.32);
+    float custom_weight =
+        clamp(max(max(base_color.r, base_color.g), base_color.b), 0.0, 1.0);
+    vec3 bronze_base = mix(bronze_default, base_color, custom_weight);
+    bronze_base = max(bronze_base, vec3(0.02));
+    albedo = bronze_base;
+    albedo -= vec3(patina * 0.18);
+    albedo += vec3(edge_polish);
+    roughness = 0.30 + patina * 0.10;
+    metallic = 0.92;
+    sheen = 0.16;
+    wrap = 0.42;
+  } else {
+    float detail = noise(uv * 12.0) * 0.10;
+    albedo = mix(vec3(0.6, 0.6, 0.6), teamColor, 0.25);
+    if (u_useTexture) {
+      albedo *= max(texture(u_texture, v_texCoord).rgb, vec3(0.35));
+    }
+    albedo *= 1.0 + detail - 0.03;
+  }
+
+  vec3 color = apply_lighting(albedo, N_used, V, L, roughness, metallic, ao,
+                              sheen, wrap);
+  color = pow(color * 1.25, vec3(0.9));
+  FragColor = vec4(clamp(color, 0.0, 1.0), u_alpha);
+}

+ 72 - 0
assets/shaders/builder_carthage.vert

@@ -0,0 +1,72 @@
+#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;
+uniform int u_materialId;
+
+out vec3 v_normal;
+out vec3 v_worldNormal;
+out vec3 v_tangent;
+out vec3 v_bitangent;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+out float v_armorLayer;
+out float v_bodyHeight;
+out float v_clothFolds;
+out float v_fabricWear;
+
+float hash13(vec3 p) {
+  return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
+}
+
+vec3 fallbackUp(vec3 n) {
+  return (abs(n.y) > 0.92) ? vec3(0.0, 0.0, 1.0) : vec3(0.0, 1.0, 0.0);
+}
+
+void main() {
+  vec3 position = a_position;
+  vec3 normal = a_normal;
+
+  mat3 normalMatrix = mat3(transpose(inverse(u_model)));
+  vec3 worldNormal = normalize(normalMatrix * normal);
+
+  vec3 t = normalize(cross(fallbackUp(worldNormal), worldNormal));
+  if (length(t) < 1e-4)
+    t = vec3(1.0, 0.0, 0.0);
+  t = normalize(t - worldNormal * dot(worldNormal, t));
+  vec3 b = normalize(cross(worldNormal, t));
+
+  v_normal = normal;
+  v_worldNormal = worldNormal;
+  v_tangent = t;
+  v_bitangent = b;
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(position, 1.0));
+
+  v_bodyHeight = clamp((v_worldPos.y + 0.2) / 1.8, 0.0, 1.0);
+
+  float chestGather = smoothstep(1.05, 1.20, v_worldPos.y) *
+                      smoothstep(1.35, 1.20, v_worldPos.y);
+  float waistSash = smoothstep(0.80, 0.92, v_worldPos.y) *
+                    smoothstep(1.04, 0.92, v_worldPos.y);
+  float hemFlow = smoothstep(0.50, 0.35, v_worldPos.y) *
+                  smoothstep(0.15, 0.35, v_worldPos.y);
+  float fold_wave = sin(v_worldPos.x * 3.6 + v_worldPos.z * 4.1) * 0.10 +
+                    sin(v_worldPos.x * 6.2 - v_worldPos.z * 3.1) * 0.06;
+  v_clothFolds = clamp((chestGather * 0.6 + waistSash * 0.8 + hemFlow * 0.5) +
+                           fold_wave * 0.35,
+                       0.0, 1.2);
+
+  float shoulderStress = smoothstep(1.05, 1.45, v_worldPos.y) * 0.35;
+  float hemWear = smoothstep(0.55, 0.15, v_worldPos.y) * 0.45;
+  v_fabricWear =
+      hash13(v_worldPos * 0.4) * 0.22 + 0.14 + shoulderStress + hemWear;
+
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
+
+  gl_Position = u_mvp * vec4(position, 1.0);
+}

+ 279 - 0
assets/shaders/builder_roman_republic.frag

@@ -0,0 +1,279 @@
+#version 330 core
+
+in vec3 v_normal;
+in vec3 v_worldNormal;
+in vec3 v_tangent;
+in vec3 v_bitangent;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+in float v_armorLayer;
+in float v_bodyHeight;
+in float v_clothFolds;
+in float v_fabricWear;
+
+uniform sampler2D u_texture;
+uniform vec3 u_color;
+uniform bool u_useTexture;
+uniform float u_alpha;
+uniform int u_materialId;
+
+out vec4 FragColor;
+
+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 sum = 0.0;
+  float amp = 0.5;
+  float freq = 1.0;
+  for (int i = 0; i < 4; ++i) {
+    sum += amp * noise(p * freq);
+    freq *= 2.1;
+    amp *= 0.48;
+  }
+  return sum;
+}
+
+float triplanar_noise(vec3 pos, vec3 normal, float scale) {
+  vec3 w = abs(normal);
+  w = max(w, vec3(0.0001));
+  w /= (w.x + w.y + w.z);
+  float xy = noise(pos.xy * scale);
+  float yz = noise(pos.yz * scale);
+  float zx = noise(pos.zx * scale);
+  return xy * w.z + yz * w.x + zx * w.y;
+}
+
+float cloth_weave(vec2 p) {
+  float warp_thread = sin(p.x * 72.0);
+  float weft_thread = sin(p.y * 70.0);
+  return warp_thread * weft_thread * 0.055;
+}
+
+float roman_linen(vec2 p) {
+  float weave = cloth_weave(p);
+  float fine_thread = noise(p * 95.0) * 0.06;
+  float slub = fbm(p * 7.5) * 0.05;
+  return weave + fine_thread + slub;
+}
+
+vec3 perturb_linen_normal(vec3 N, vec3 T, vec3 B, vec2 uv) {
+  float warp = sin(uv.x * 142.0) * 0.05;
+  float weft = sin(uv.y * 138.0) * 0.05;
+  float slub = fbm(uv * 7.0) * 0.04;
+  return normalize(N + T * (warp + slub) + B * (weft + slub * 0.6));
+}
+
+vec3 perturb_leather_normal(vec3 N, vec3 T, vec3 B, vec2 uv) {
+  float grain = fbm(uv * 8.5) * 0.16;
+  float pores = noise(uv * 34.0) * 0.10;
+  float scars = noise(uv * 16.0 + vec2(2.7, -1.9)) * 0.07;
+  return normalize(N + T * (grain + scars * 0.4) + B * (pores + scars * 0.3));
+}
+
+vec3 perturb_bronze_normal(vec3 N, vec3 T, vec3 B, vec2 uv) {
+  float hammer = fbm(uv * 15.0) * 0.14;
+  float ripple = noise(uv * 46.0) * 0.05;
+  return normalize(N + T * hammer + B * (hammer * 0.4 + ripple));
+}
+
+float D_GGX(float NdotH, float a) {
+  float a2 = a * a;
+  float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
+  return a2 / max(3.14159 * d * d, 1e-5);
+}
+
+float G_Smith(float NdotV, float NdotL, float a) {
+  float k = (a + 1.0);
+  k = (k * k) / 8.0;
+  float g_v = NdotV / (NdotV * (1.0 - k) + k);
+  float g_l = NdotL / (NdotL * (1.0 - k) + k);
+  return g_v * g_l;
+}
+
+vec3 fresnel_schlick(vec3 F0, float cos_theta) {
+  return F0 + (vec3(1.0) - F0) * pow(1.0 - cos_theta, 5.0);
+}
+
+vec3 compute_ambient(vec3 normal) {
+  float up = clamp(normal.y, 0.0, 1.0);
+  float down = clamp(-normal.y, 0.0, 1.0);
+  vec3 sky = vec3(0.66, 0.76, 0.90);
+  vec3 ground = vec3(0.42, 0.36, 0.30);
+  return sky * (0.26 + 0.54 * up) + ground * (0.14 + 0.30 * down);
+}
+
+vec3 apply_lighting(vec3 albedo, vec3 N, vec3 V, vec3 L, float roughness,
+                    float metallic, float ao, float sheen, float wrap) {
+  float NdotL = max(dot(N, L), 0.0);
+  float NdotV = max(dot(N, V), 0.0);
+  float wrapped = clamp(NdotL * (1.0 - wrap) + wrap, 0.0, 1.0);
+  NdotL = wrapped;
+
+  vec3 H = normalize(L + V);
+  float NdotH = max(dot(N, H), 0.0);
+  float VdotH = max(dot(V, H), 0.0);
+
+  float a = max(roughness * roughness, 0.03);
+  float D = D_GGX(NdotH, a);
+  float G = G_Smith(NdotV, NdotL, a);
+
+  vec3 F0 = mix(vec3(0.04), albedo, metallic);
+  vec3 F = fresnel_schlick(F0, VdotH);
+
+  vec3 spec = (D * G * F) / max(4.0 * NdotV * NdotL + 1e-5, 1e-5);
+  vec3 kd = (vec3(1.0) - F) * (1.0 - metallic);
+  vec3 diffuse = kd * albedo / 3.14159;
+
+  vec3 ambient = compute_ambient(N) * albedo;
+  vec3 light = (diffuse + spec * (1.0 + sheen)) * NdotL;
+
+  float ao_strength = mix(0.35, 1.0, clamp(ao, 0.0, 1.0));
+  return ambient * (0.56 + 0.44 * ao_strength) + light * ao_strength;
+}
+
+void main() {
+  vec3 base_color = u_color;
+  if (u_useTexture) {
+    base_color *= texture(u_texture, v_texCoord).rgb;
+  }
+
+  vec3 N = normalize(v_worldNormal);
+  vec3 T = normalize(v_tangent);
+  vec3 B = normalize(v_bitangent);
+  vec2 uv = v_worldPos.xz * 4.5;
+
+  bool is_body = (u_materialId == 0);
+  bool is_tunica = (u_materialId == 1);
+  bool is_leather = (u_materialId == 2);
+  bool is_tools = (u_materialId == 3);
+
+  vec3 albedo = base_color;
+  vec3 N_used = N;
+  float roughness = 0.55;
+  float metallic = 0.0;
+  float sheen = 0.0;
+  float wrap = 0.44;
+
+  vec3 V = normalize(vec3(-0.25, 1.0, 0.4));
+  vec3 L = normalize(vec3(1.0, 1.25, 1.0));
+
+  float curvature = length(dFdx(N)) + length(dFdy(N));
+  float ao_folds =
+      clamp(1.0 - (v_clothFolds * 0.52 + curvature * 0.78), 0.28, 1.0);
+  float ao = ao_folds;
+
+  if (is_body) {
+    vec3 skin = base_color;
+    float skin_detail = noise(uv * 24.0) * 0.06;
+    float subdermal = noise(uv * 7.0) * 0.05;
+    skin *= 1.0 + skin_detail;
+    skin += vec3(0.03, 0.015, 0.0) * subdermal;
+
+    float rim = pow(1.0 - clamp(dot(N_used, V), 0.0, 1.0), 4.0) * 0.05;
+    skin += vec3(rim);
+
+    albedo = skin;
+    roughness = 0.55;
+    sheen = 0.06 + subdermal * 0.2;
+    wrap = 0.46;
+  }
+
+  else if (is_tunica) {
+    vec3 tunic_base = vec3(0.72, 0.58, 0.42);
+    albedo = tunic_base;
+
+    float linen = roman_linen(v_worldPos.xz);
+    float fine_thread = noise(uv * 98.0) * 0.05;
+
+    float fold_depth = v_clothFolds * noise(uv * 14.0) * 0.16;
+    float wear_pattern = v_fabricWear * noise(uv * 9.0) * 0.11;
+
+    float dust =
+        smoothstep(0.24, 0.0, v_bodyHeight) * (0.10 + noise(uv * 6.5) * 0.10);
+
+    N_used = perturb_linen_normal(N, T, B, uv);
+
+    albedo *= 1.0 + linen + fine_thread - 0.025;
+    albedo -= vec3(fold_depth + wear_pattern);
+    albedo -= vec3(dust * 0.18);
+
+    roughness = 0.70 - clamp(v_fabricWear * 0.08, 0.0, 0.12);
+    sheen = 0.08;
+    wrap = 0.54;
+    ao *= 1.0 - dust * 0.35;
+  }
+
+  else if (is_leather) {
+    float leather_grain = noise(uv * 16.0) * 0.16 * (1.0 + v_fabricWear * 0.25);
+    float pores = noise(uv * 38.0) * 0.06;
+
+    float tooling = noise(uv * 24.0) * 0.05;
+    float stitching = step(0.94, fract(v_worldPos.x * 16.0)) *
+                      step(0.94, fract(v_worldPos.y * 14.0)) * 0.07;
+
+    float oil_darkening = v_fabricWear * 0.12;
+    float conditioning = noise(uv * 6.0) * 0.04;
+
+    float view_angle = max(dot(N, V), 0.0);
+    float leather_sheen = pow(1.0 - view_angle, 5.0) * 0.12;
+
+    float edge_wear = smoothstep(0.86, 0.92, abs(dot(N, T))) * 0.09;
+
+    N_used = perturb_leather_normal(N, T, B, uv);
+
+    albedo *=
+        1.0 + leather_grain + pores + tooling + conditioning - oil_darkening;
+    albedo += vec3(stitching + leather_sheen + edge_wear);
+
+    roughness = 0.56 - clamp(v_fabricWear * 0.06, 0.0, 0.10);
+    sheen = 0.08;
+    wrap = 0.46;
+  }
+
+  else if (is_tools) {
+    vec3 iron_base = vec3(0.52, 0.50, 0.48);
+
+    float patina = noise(uv * 13.0) * 0.16;
+    float rust = noise(uv * 20.0) * 0.08;
+
+    float view_angle = max(dot(N, V), 0.0);
+    float iron_sheen = pow(view_angle, 9.0) * 0.32;
+
+    float edge_polish = smoothstep(0.85, 0.95, abs(dot(N, T))) * 0.12;
+
+    N_used = perturb_bronze_normal(N, T, B, uv);
+
+    albedo = mix(base_color, iron_base, 0.58);
+    albedo -= vec3(patina * 0.22 + rust * 0.14);
+    albedo += vec3(iron_sheen + edge_polish);
+
+    roughness = 0.38 + patina * 0.12;
+    metallic = 0.85;
+    sheen = 0.12;
+    wrap = 0.42;
+  }
+
+  else {
+    float detail = noise(uv * 11.0) * 0.09;
+    albedo *= 1.0 + detail - 0.05;
+  }
+
+  vec3 color = apply_lighting(albedo, N_used, V, L, roughness, metallic, ao,
+                              sheen, wrap);
+  FragColor = vec4(clamp(color, 0.0, 1.0), u_alpha);
+}

+ 65 - 0
assets/shaders/builder_roman_republic.vert

@@ -0,0 +1,65 @@
+#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;
+uniform int u_materialId;
+
+out vec3 v_normal;
+out vec3 v_worldNormal;
+out vec3 v_tangent;
+out vec3 v_bitangent;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+out float v_armorLayer;
+out float v_bodyHeight;
+out float v_clothFolds;
+out float v_fabricWear;
+
+float hash13(vec3 p) {
+  return fract(sin(dot(p, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
+}
+
+vec3 fallbackUp(vec3 n) {
+  return (abs(n.y) > 0.92) ? vec3(0.0, 0.0, 1.0) : vec3(0.0, 1.0, 0.0);
+}
+
+void main() {
+  vec3 position = a_position;
+  vec3 normal = a_normal;
+
+  mat3 normalMatrix = mat3(transpose(inverse(u_model)));
+  vec3 worldNormal = normalize(normalMatrix * normal);
+
+  vec3 t = normalize(cross(fallbackUp(worldNormal), worldNormal));
+  if (length(t) < 1e-4)
+    t = vec3(1.0, 0.0, 0.0);
+  t = normalize(t - worldNormal * dot(worldNormal, t));
+  vec3 b = normalize(cross(worldNormal, t));
+
+  v_normal = normal;
+  v_worldNormal = worldNormal;
+  v_tangent = t;
+  v_bitangent = b;
+  v_texCoord = a_texCoord;
+  v_worldPos = vec3(u_model * vec4(position, 1.0));
+
+  v_bodyHeight = clamp((v_worldPos.y + 0.2) / 1.8, 0.0, 1.0);
+
+  float elbowFolds = smoothstep(1.15, 1.25, v_worldPos.y) *
+                     smoothstep(1.35, 1.25, v_worldPos.y);
+  float waistFolds = smoothstep(0.85, 0.95, v_worldPos.y) *
+                     smoothstep(1.05, 0.95, v_worldPos.y);
+  float kneeFolds = smoothstep(0.45, 0.55, v_worldPos.y) *
+                    smoothstep(0.65, 0.55, v_worldPos.y);
+  v_clothFolds = (elbowFolds + waistFolds + kneeFolds) * 0.5;
+
+  v_fabricWear = hash13(v_worldPos * 0.5) * 0.3 + 0.2;
+
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
+
+  gl_Position = u_mvp * vec4(position, 1.0);
+}

BIN
assets/visuals/icons/builder_cartaghe.png


BIN
assets/visuals/icons/builder_rome.png


BIN
assets/visuals/icons/defense_tower_cartaghe.png


BIN
assets/visuals/icons/defense_tower_rome.png


BIN
assets/visuals/icons/healer_rom e.png


BIN
assets/visuals/icons/house_cartaghe.png


BIN
assets/visuals/icons/house_rome.png


BIN
assets/visuals/icons/wall_cartaghe.png


BIN
assets/visuals/icons/wall_rome.png


+ 1 - 0
game/CMakeLists.txt

@@ -100,6 +100,7 @@ add_library(game_systems STATIC
     units/horse_spearman.cpp
     units/horse_spearman.cpp
     units/spearman.cpp
     units/spearman.cpp
     units/healer.cpp
     units/healer.cpp
+    units/builder.cpp
     units/catapult.cpp
     units/catapult.cpp
     units/ballista.cpp
     units/ballista.cpp
     units/troop_catalog.cpp
     units/troop_catalog.cpp

+ 11 - 0
game/core/component.h

@@ -265,6 +265,17 @@ public:
   bool is_being_captured{false};
   bool is_being_captured{false};
 };
 };
 
 
+class BuilderProductionComponent : public Component {
+public:
+  BuilderProductionComponent() = default;
+
+  bool in_progress{false};
+  float build_time{10.0F};
+  float time_remaining{0.0F};
+  std::string product_type{};
+  bool construction_complete{false};
+};
+
 class PendingRemovalComponent : public Component {
 class PendingRemovalComponent : public Component {
 public:
 public:
   PendingRemovalComponent() = default;
   PendingRemovalComponent() = default;

+ 56 - 0
game/systems/production_system.cpp

@@ -127,6 +127,62 @@ void ProductionSystem::update(Engine::Core::World *world, float delta_time) {
       }
       }
     }
     }
   }
   }
+
+  auto builder_entities =
+      world->get_entities_with<Engine::Core::BuilderProductionComponent>();
+  for (auto *e : builder_entities) {
+    auto *builder_prod =
+        e->get_component<Engine::Core::BuilderProductionComponent>();
+    if (builder_prod == nullptr || !builder_prod->in_progress) {
+      continue;
+    }
+
+    builder_prod->time_remaining -= delta_time;
+    if (builder_prod->time_remaining <= 0.0F) {
+
+      auto *t = e->get_component<Engine::Core::TransformComponent>();
+      auto *u = e->get_component<Engine::Core::UnitComponent>();
+      if ((t != nullptr) && (u != nullptr)) {
+        auto reg = Game::Map::MapTransformer::getFactoryRegistry();
+        if (reg) {
+          Game::Units::SpawnParams sp;
+
+          float const spawn_offset = 2.5F;
+          float forward_x = 0.0F;
+          float forward_z = 1.0F;
+          float yaw = t->rotation.y;
+          forward_x = std::sin(yaw);
+          forward_z = std::cos(yaw);
+          sp.position =
+              QVector3D(t->position.x + forward_x * spawn_offset, t->position.y,
+                        t->position.z + forward_z * spawn_offset);
+          sp.player_id = u->owner_id;
+          sp.ai_controlled =
+              e->has_component<Engine::Core::AIControlledComponent>();
+          sp.nation_id = u->nation_id;
+
+          if (builder_prod->product_type == "catapult") {
+            sp.spawn_type = Game::Units::SpawnType::Catapult;
+          } else if (builder_prod->product_type == "ballista") {
+            sp.spawn_type = Game::Units::SpawnType::Ballista;
+          } else if (builder_prod->product_type == "defense_tower") {
+            sp.spawn_type = Game::Units::SpawnType::DefenseTower;
+          } else {
+
+            builder_prod->in_progress = false;
+            builder_prod->time_remaining = 0.0F;
+            continue;
+          }
+
+          reg->create(sp.spawn_type, *world, sp);
+        }
+      }
+
+      builder_prod->in_progress = false;
+      builder_prod->time_remaining = 0.0F;
+      builder_prod->construction_complete = true;
+    }
+  }
 }
 }
 
 
 } // namespace Game::Systems
 } // namespace Game::Systems

+ 111 - 0
game/units/builder.cpp

@@ -0,0 +1,111 @@
+#include "builder.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.9F, 1.0F};
+  }
+}
+
+namespace Game::Units {
+
+Builder::Builder(Engine::Core::World &world)
+    : Unit(world, TroopType::Builder) {}
+
+auto Builder::Create(Engine::Core::World &world,
+                     const SpawnParams &params) -> std::unique_ptr<Builder> {
+  auto unit = std::unique_ptr<Builder>(new Builder(world));
+  unit->init(params);
+  return unit;
+}
+
+void Builder::init(const SpawnParams &params) {
+
+  auto *e = m_world->create_entity();
+  m_id = e->get_id();
+
+  const auto nation_id = resolve_nation_id(params);
+  auto profile = Game::Systems::TroopProfileService::instance().get_profile(
+      nation_id, TroopType::Builder);
+
+  m_t = e->add_component<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->add_component<Engine::Core::RenderableComponent>("", "");
+  m_r->visible = true;
+  m_r->renderer_id = profile.visuals.renderer_id;
+
+  m_u = e->add_component<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.ai_controlled) {
+    e->add_component<Engine::Core::AIControlledComponent>();
+  }
+
+  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->add_component<Engine::Core::MovementComponent>();
+  if (m_mv != nullptr) {
+    m_mv->goal_x = params.position.x();
+    m_mv->goal_y = params.position.z();
+    m_mv->target_x = params.position.x();
+    m_mv->target_y = params.position.z();
+  }
+
+  m_atk = e->add_component<Engine::Core::AttackComponent>();
+  if (m_atk != nullptr) {
+    m_atk->range = profile.combat.ranged_range;
+    m_atk->damage = profile.combat.ranged_damage;
+    m_atk->cooldown = profile.combat.ranged_cooldown;
+
+    m_atk->melee_range = profile.combat.melee_range;
+    m_atk->melee_damage = profile.combat.melee_damage;
+    m_atk->melee_cooldown = profile.combat.melee_cooldown;
+
+    m_atk->preferred_mode =
+        profile.combat.can_ranged
+            ? Engine::Core::AttackComponent::CombatMode::Ranged
+            : Engine::Core::AttackComponent::CombatMode::Melee;
+    m_atk->current_mode =
+        profile.combat.can_ranged
+            ? Engine::Core::AttackComponent::CombatMode::Ranged
+            : Engine::Core::AttackComponent::CombatMode::Melee;
+    m_atk->can_ranged = profile.combat.can_ranged;
+    m_atk->can_melee = profile.combat.can_melee;
+  }
+
+  e->add_component<Engine::Core::BuilderProductionComponent>();
+
+  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/builder.h

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

+ 6 - 0
game/units/factory.cpp

@@ -2,6 +2,7 @@
 #include "archer.h"
 #include "archer.h"
 #include "ballista.h"
 #include "ballista.h"
 #include "barracks.h"
 #include "barracks.h"
+#include "builder.h"
 #include "catapult.h"
 #include "catapult.h"
 #include "defense_tower.h"
 #include "defense_tower.h"
 #include "healer.h"
 #include "healer.h"
@@ -61,6 +62,11 @@ void registerBuiltInUnits(UnitFactoryRegistry &reg) {
     return Ballista::Create(world, params);
     return Ballista::Create(world, params);
   });
   });
 
 
+  reg.registerFactory(SpawnType::Builder, [](Engine::Core::World &world,
+                                             const SpawnParams &params) {
+    return Builder::Create(world, params);
+  });
+
   reg.registerFactory(SpawnType::Barracks, [](Engine::Core::World &world,
   reg.registerFactory(SpawnType::Barracks, [](Engine::Core::World &world,
                                               const SpawnParams &params) {
                                               const SpawnParams &params) {
     return Barracks::Create(world, params);
     return Barracks::Create(world, params);

+ 18 - 2
game/units/spawn_type.h

@@ -19,6 +19,7 @@ enum class SpawnType : std::uint8_t {
   Healer,
   Healer,
   Catapult,
   Catapult,
   Ballista,
   Ballista,
+  Builder,
   Barracks,
   Barracks,
   DefenseTower
   DefenseTower
 };
 };
@@ -43,6 +44,8 @@ inline auto spawn_typeToQString(SpawnType type) -> QString {
     return QStringLiteral("catapult");
     return QStringLiteral("catapult");
   case SpawnType::Ballista:
   case SpawnType::Ballista:
     return QStringLiteral("ballista");
     return QStringLiteral("ballista");
+  case SpawnType::Builder:
+    return QStringLiteral("builder");
   case SpawnType::Barracks:
   case SpawnType::Barracks:
     return QStringLiteral("barracks");
     return QStringLiteral("barracks");
   case SpawnType::DefenseTower:
   case SpawnType::DefenseTower:
@@ -94,6 +97,10 @@ inline auto tryParseSpawnType(const QString &value, SpawnType &out) -> bool {
     out = SpawnType::Ballista;
     out = SpawnType::Ballista;
     return true;
     return true;
   }
   }
+  if (lowered == QStringLiteral("builder")) {
+    out = SpawnType::Builder;
+    return true;
+  }
   if (lowered == QStringLiteral("barracks")) {
   if (lowered == QStringLiteral("barracks")) {
     out = SpawnType::Barracks;
     out = SpawnType::Barracks;
     return true;
     return true;
@@ -134,6 +141,9 @@ spawn_typeFromString(const std::string &str) -> std::optional<SpawnType> {
   if (str == "ballista") {
   if (str == "ballista") {
     return SpawnType::Ballista;
     return SpawnType::Ballista;
   }
   }
+  if (str == "builder") {
+    return SpawnType::Builder;
+  }
   if (str == "barracks") {
   if (str == "barracks") {
     return SpawnType::Barracks;
     return SpawnType::Barracks;
   }
   }
@@ -152,8 +162,8 @@ inline auto is_building_spawn(SpawnType type) -> bool {
 }
 }
 
 
 inline auto can_use_attack_mode(SpawnType type) -> bool {
 inline auto can_use_attack_mode(SpawnType type) -> bool {
-  return type != SpawnType::Healer && type != SpawnType::Barracks &&
-         type != SpawnType::DefenseTower;
+  return type != SpawnType::Healer && type != SpawnType::Builder &&
+         type != SpawnType::Barracks && type != SpawnType::DefenseTower;
 }
 }
 
 
 inline auto can_use_guard_mode(SpawnType type) -> bool {
 inline auto can_use_guard_mode(SpawnType type) -> bool {
@@ -189,8 +199,12 @@ inline auto spawn_typeToTroopType(SpawnType type) -> std::optional<TroopType> {
     return TroopType::Catapult;
     return TroopType::Catapult;
   case SpawnType::Ballista:
   case SpawnType::Ballista:
     return TroopType::Ballista;
     return TroopType::Ballista;
+  case SpawnType::Builder:
+    return TroopType::Builder;
   case SpawnType::Barracks:
   case SpawnType::Barracks:
     return std::nullopt;
     return std::nullopt;
+  case SpawnType::DefenseTower:
+    return std::nullopt;
   }
   }
   return std::nullopt;
   return std::nullopt;
 }
 }
@@ -215,6 +229,8 @@ inline auto spawn_typeFromTroopType(TroopType type) -> SpawnType {
     return SpawnType::Catapult;
     return SpawnType::Catapult;
   case TroopType::Ballista:
   case TroopType::Ballista:
     return SpawnType::Ballista;
     return SpawnType::Ballista;
+  case TroopType::Builder:
+    return SpawnType::Builder;
   }
   }
   return SpawnType::Archer;
   return SpawnType::Archer;
 }
 }

+ 32 - 0
game/units/troop_catalog.cpp

@@ -262,6 +262,38 @@ void TroopCatalog::register_defaults() {
   horse_spearman.max_units_per_row = 3;
   horse_spearman.max_units_per_row = 3;
 
 
   register_class(std::move(horse_spearman));
   register_class(std::move(horse_spearman));
+
+  TroopClass builder{};
+  builder.unit_type = Game::Units::TroopType::Builder;
+  builder.display_name = "Builder";
+  builder.production.cost = 60;
+  builder.production.build_time = 6.0F;
+  builder.production.priority = 4;
+  builder.production.is_melee = true;
+
+  builder.combat.health = 80;
+  builder.combat.max_health = 80;
+  builder.combat.speed = 2.0F;
+  builder.combat.vision_range = 10.0F;
+  builder.combat.ranged_range = 1.5F;
+  builder.combat.ranged_damage = 2;
+  builder.combat.ranged_cooldown = 2.0F;
+  builder.combat.melee_range = 1.5F;
+  builder.combat.melee_damage = 5;
+  builder.combat.melee_cooldown = 1.0F;
+  builder.combat.can_ranged = false;
+  builder.combat.can_melee = true;
+
+  builder.visuals.render_scale = 0.50F;
+  builder.visuals.selection_ring_size = 1.0F;
+  builder.visuals.selection_ring_ground_offset = 0.0F;
+  builder.visuals.selection_ring_y_offset = 0.0F;
+  builder.visuals.renderer_id = "troops/roman/builder";
+
+  builder.individuals_per_unit = 12;
+  builder.max_units_per_row = 4;
+
+  register_class(std::move(builder));
 }
 }
 
 
 } // namespace Game::Units
 } // namespace Game::Units

+ 8 - 1
game/units/troop_type.h

@@ -18,7 +18,8 @@ enum class TroopType {
   HorseSpearman,
   HorseSpearman,
   Healer,
   Healer,
   Catapult,
   Catapult,
-  Ballista
+  Ballista,
+  Builder
 };
 };
 
 
 inline auto troop_typeToQString(TroopType type) -> QString {
 inline auto troop_typeToQString(TroopType type) -> QString {
@@ -41,6 +42,8 @@ inline auto troop_typeToQString(TroopType type) -> QString {
     return QStringLiteral("catapult");
     return QStringLiteral("catapult");
   case TroopType::Ballista:
   case TroopType::Ballista:
     return QStringLiteral("ballista");
     return QStringLiteral("ballista");
+  case TroopType::Builder:
+    return QStringLiteral("builder");
   }
   }
   return QStringLiteral("archer");
   return QStringLiteral("archer");
 }
 }
@@ -91,6 +94,10 @@ inline auto tryParseTroopType(const QString &value, TroopType &out) -> bool {
     out = TroopType::Ballista;
     out = TroopType::Ballista;
     return true;
     return true;
   }
   }
+  if (lowered == QStringLiteral("builder")) {
+    out = TroopType::Builder;
+    return true;
+  }
   return false;
   return false;
 }
 }
 
 

+ 38 - 0
qml_resources.qrc

@@ -24,5 +24,43 @@
         <file>ui/qml/GameView.qml</file>
         <file>ui/qml/GameView.qml</file>
         <file>ui/qml/CursorManager.qml</file>
         <file>ui/qml/CursorManager.qml</file>
         <file>ui/qml/LoadScreen.qml</file>
         <file>ui/qml/LoadScreen.qml</file>
+        <!-- Icon assets mirrored under the QML module prefix for Qt6 -->
+        <file alias="assets/visuals/icons/archer_cartaghe.png">assets/visuals/icons/archer_cartaghe.png</file>
+        <file alias="assets/visuals/icons/archer_rome.png">assets/visuals/icons/archer_rome.png</file>
+        <file alias="assets/visuals/icons/ballista_cartaghe.png">assets/visuals/icons/ballista_cartaghe.png</file>
+        <file alias="assets/visuals/icons/ballista_rome.png">assets/visuals/icons/ballista_rome.png</file>
+        <file alias="assets/visuals/icons/builder_cartaghe.png">assets/visuals/icons/builder_cartaghe.png</file>
+        <file alias="assets/visuals/icons/builder_rome.png">assets/visuals/icons/builder_rome.png</file>
+        <file alias="assets/visuals/icons/catapult_cartaghe.png">assets/visuals/icons/catapult_cartaghe.png</file>
+        <file alias="assets/visuals/icons/catapult_rome.png">assets/visuals/icons/catapult_rome.png</file>
+        <file alias="assets/visuals/icons/defense_tower_cartaghe.png">assets/visuals/icons/defense_tower_cartaghe.png</file>
+        <file alias="assets/visuals/icons/defense_tower_rome.png">assets/visuals/icons/defense_tower_rome.png</file>
+        <file alias="assets/visuals/icons/healer_cartaghe.png">assets/visuals/icons/healer_cartaghe.png</file>
+        <file alias="assets/visuals/icons/healer_rome.png">assets/visuals/icons/healer_rome.png</file>
+        <file alias="assets/visuals/icons/horse_archer_cartaghe.png">assets/visuals/icons/horse_archer_cartaghe.png</file>
+        <file alias="assets/visuals/icons/horse_archer_rome.png">assets/visuals/icons/horse_archer_rome.png</file>
+        <file alias="assets/visuals/icons/horse_spearman_cartaghe.png">assets/visuals/icons/horse_spearman_cartaghe.png</file>
+        <file alias="assets/visuals/icons/horse_spearman_rome.png">assets/visuals/icons/horse_spearman_rome.png</file>
+        <file alias="assets/visuals/icons/horse_swordsman_cartaghe.png">assets/visuals/icons/horse_swordsman_cartaghe.png</file>
+        <file alias="assets/visuals/icons/horse_swordsman_rome.png">assets/visuals/icons/horse_swordsman_rome.png</file>
+        <file alias="assets/visuals/icons/spearman_cartaghe.png">assets/visuals/icons/spearman_cartaghe.png</file>
+        <file alias="assets/visuals/icons/spearman_rome.png">assets/visuals/icons/spearman_rome.png</file>
+        <file alias="assets/visuals/icons/swordsman_cartaghe.png">assets/visuals/icons/swordsman_cartaghe.png</file>
+        <file alias="assets/visuals/icons/swordsman_rome.png">assets/visuals/icons/swordsman_rome.png</file>
+        <file alias="assets/visuals/icons/wall_cartaghe.png">assets/visuals/icons/wall_cartaghe.png</file>
+        <file alias="assets/visuals/icons/wall_rome.png">assets/visuals/icons/wall_rome.png</file>
+        <file alias="assets/visuals/icons/house_cartaghe.png">assets/visuals/icons/house_cartaghe.png</file>
+        <file alias="assets/visuals/icons/house_rome.png">assets/visuals/icons/house_rome.png</file>
+        <file alias="assets/visuals/load_screen.png">assets/visuals/load_screen.png</file>
+    </qresource>
+    <!-- Duplicate critical assets under the Qt6-installed module prefix -->
+    <qresource prefix="/qt/qml/StandardOfIron">
+        <file alias="assets/visuals/icons/defense_tower_cartaghe.png">assets/visuals/icons/defense_tower_cartaghe.png</file>
+        <file alias="assets/visuals/icons/defense_tower_rome.png">assets/visuals/icons/defense_tower_rome.png</file>
+        <file alias="assets/visuals/icons/wall_cartaghe.png">assets/visuals/icons/wall_cartaghe.png</file>
+        <file alias="assets/visuals/icons/wall_rome.png">assets/visuals/icons/wall_rome.png</file>
+        <file alias="assets/visuals/icons/house_cartaghe.png">assets/visuals/icons/house_cartaghe.png</file>
+        <file alias="assets/visuals/icons/house_rome.png">assets/visuals/icons/house_rome.png</file>
+        <file alias="assets/visuals/load_screen.png">assets/visuals/load_screen.png</file>
     </qresource>
     </qresource>
 </RCC>
 </RCC>

+ 4 - 0
render/CMakeLists.txt

@@ -72,6 +72,10 @@ add_library(render_gl STATIC
     entity/nations/roman/healer_style.cpp
     entity/nations/roman/healer_style.cpp
     entity/nations/carthage/healer_renderer.cpp
     entity/nations/carthage/healer_renderer.cpp
     entity/nations/carthage/healer_style.cpp
     entity/nations/carthage/healer_style.cpp
+    entity/nations/roman/builder_renderer.cpp
+    entity/nations/roman/builder_style.cpp
+    entity/nations/carthage/builder_renderer.cpp
+    entity/nations/carthage/builder_style.cpp
     entity/healing_beam_renderer.cpp
     entity/healing_beam_renderer.cpp
     entity/healer_aura_renderer.cpp
     entity/healer_aura_renderer.cpp
     entity/combat_dust_renderer.cpp
     entity/combat_dust_renderer.cpp

+ 40 - 0
render/entity/combat_dust_renderer.cpp

@@ -112,6 +112,46 @@ void render_combat_dust(Renderer *renderer, ResourceManager *,
                           animation_time);
                           animation_time);
   }
   }
 
 
+  auto builders =
+      world->get_entities_with<Engine::Core::BuilderProductionComponent>();
+
+  for (auto *builder : builders) {
+    if (builder->has_component<Engine::Core::PendingRemovalComponent>()) {
+      continue;
+    }
+
+    auto *transform =
+        builder->get_component<Engine::Core::TransformComponent>();
+    auto *production =
+        builder->get_component<Engine::Core::BuilderProductionComponent>();
+    auto *unit_comp = builder->get_component<Engine::Core::UnitComponent>();
+
+    if (transform == nullptr || production == nullptr) {
+      continue;
+    }
+
+    if (unit_comp != nullptr && unit_comp->health <= 0) {
+      continue;
+    }
+
+    if (!production->in_progress) {
+      continue;
+    }
+
+    if (!visibility.is_entity_visible(transform->position.x,
+                                      transform->position.z,
+                                      kVisibilityCheckRadius)) {
+      continue;
+    }
+
+    QVector3D position(transform->position.x, kDustYOffset,
+                       transform->position.z);
+    QVector3D color(kDustColorR, kDustColorG, kDustColorB);
+
+    renderer->combat_dust(position, color, kDustRadius, kDustIntensity,
+                          animation_time);
+  }
+
   auto buildings = world->get_entities_with<Engine::Core::BuildingComponent>();
   auto buildings = world->get_entities_with<Engine::Core::BuildingComponent>();
 
 
   for (auto *building : buildings) {
   for (auto *building : buildings) {

+ 461 - 0
render/entity/nations/carthage/builder_renderer.cpp

@@ -0,0 +1,461 @@
+#include "builder_renderer.h"
+#include "../../../../game/core/component.h"
+#include "../../../../game/core/entity.h"
+#include "../../../../game/systems/nation_id.h"
+#include "../../../equipment/equipment_registry.h"
+#include "../../../geom/math_utils.h"
+#include "../../../geom/transforms.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/primitives.h"
+#include "../../../gl/render_constants.h"
+#include "../../../gl/shader.h"
+#include "../../../humanoid/humanoid_math.h"
+#include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/pose_controller.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
+#include "../../../palette.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../registry.h"
+#include "../../renderer_constants.h"
+#include "builder_style.h"
+
+#include <QMatrix4x4>
+#include <QString>
+#include <QVector3D>
+#include <cmath>
+#include <cstdint>
+#include <numbers>
+#include <optional>
+#include <qmatrix4x4.h>
+#include <qstringliteral.h>
+#include <qvectornd.h>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+
+using Render::Geom::cylinder_between;
+using Render::Geom::sphere_at;
+
+namespace Render::GL::Carthage {
+
+namespace {
+
+constexpr std::string_view k_default_style_key = "default";
+
+auto style_registry() -> std::unordered_map<std::string, BuilderStyleConfig> & {
+  static std::unordered_map<std::string, BuilderStyleConfig> styles;
+  return styles;
+}
+
+void ensure_builder_styles_registered() {
+  static const bool registered = []() {
+    register_carthage_builder_style();
+    return true;
+  }();
+  (void)registered;
+}
+
+constexpr float k_team_mix_weight = 0.65F;
+constexpr float k_style_mix_weight = 0.35F;
+
+} // namespace
+
+void register_builder_style(const std::string &nation_id,
+                            const BuilderStyleConfig &style) {
+  style_registry()[nation_id] = style;
+}
+
+using Render::Geom::clamp01;
+using Render::Geom::clampf;
+using Render::GL::Humanoid::mix_palette_color;
+using Render::GL::Humanoid::saturate_color;
+
+class BuilderRenderer : public HumanoidRendererBase {
+public:
+  auto get_proportion_scaling() const -> QVector3D override {
+    return {0.98F, 1.01F, 0.96F};
+  }
+
+  void get_variant(const DrawContext &ctx, uint32_t seed,
+                   HumanoidVariant &v) const override {
+    QVector3D const team_tint = resolve_team_tint(ctx);
+    v.palette = make_humanoid_palette(team_tint, seed);
+    auto const &style = resolve_style(ctx);
+    apply_palette_overrides(style, team_tint, v);
+
+    auto nextRand = [](uint32_t &s) -> float {
+      s = s * 1664525U + 1013904223U;
+      return float(s & 0x7FFFFFU) / float(0x7FFFFFU);
+    };
+
+    uint32_t beard_seed = seed ^ 0x0EA101U;
+    if (style.force_beard || nextRand(beard_seed) < 0.75F) {
+      float const style_roll = nextRand(beard_seed);
+      if (style_roll < 0.5F) {
+        v.facial_hair.style = FacialHairStyle::ShortBeard;
+        v.facial_hair.length = 0.7F + nextRand(beard_seed) * 0.3F;
+      } else if (style_roll < 0.8F) {
+        v.facial_hair.style = FacialHairStyle::FullBeard;
+        v.facial_hair.length = 0.8F + nextRand(beard_seed) * 0.4F;
+      } else {
+        v.facial_hair.style = FacialHairStyle::Goatee;
+        v.facial_hair.length = 0.6F + nextRand(beard_seed) * 0.3F;
+      }
+      v.facial_hair.color = QVector3D(0.15F + nextRand(beard_seed) * 0.1F,
+                                      0.12F + nextRand(beard_seed) * 0.08F,
+                                      0.10F + nextRand(beard_seed) * 0.06F);
+      v.facial_hair.thickness = 0.8F + nextRand(beard_seed) * 0.2F;
+    }
+  }
+
+  void customize_pose(const DrawContext &,
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
+    using HP = HumanProportions;
+
+    const AnimationInputs &anim = anim_ctx.inputs;
+    HumanoidPoseController controller(pose, anim_ctx);
+
+    float const jitter = (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.04F;
+    float const asym = (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.05F;
+
+    if (anim.is_constructing) {
+
+      float const phase_offset = float(seed % 100) * 0.0628F;
+      float const cycle_speed = 2.0F + float(seed % 50) * 0.02F;
+      float const swing_cycle =
+          std::fmod(anim.time * cycle_speed + phase_offset, 1.0F);
+
+      float swing_angle;
+      float body_lean;
+      float crouch_amount;
+
+      if (swing_cycle < 0.3F) {
+
+        float const t = swing_cycle / 0.3F;
+        swing_angle = t * 0.85F;
+        body_lean = -t * 0.08F;
+        crouch_amount = 0.0F;
+      } else if (swing_cycle < 0.5F) {
+
+        float const t = (swing_cycle - 0.3F) / 0.2F;
+        swing_angle = 0.85F - t * 1.3F;
+        body_lean = -0.08F + t * 0.22F;
+        crouch_amount = t * 0.06F;
+      } else if (swing_cycle < 0.6F) {
+
+        float const t = (swing_cycle - 0.5F) / 0.1F;
+        swing_angle = -0.45F + t * 0.15F;
+        body_lean = 0.14F - t * 0.04F;
+        crouch_amount = 0.06F - t * 0.02F;
+      } else {
+
+        float const t = (swing_cycle - 0.6F) / 0.4F;
+        swing_angle = -0.30F + t * 0.30F;
+        body_lean = 0.10F * (1.0F - t);
+        crouch_amount = 0.04F * (1.0F - t);
+      }
+
+      float const torso_y_offset = -crouch_amount;
+
+      float const hammer_y = HP::SHOULDER_Y + 0.10F + swing_angle * 0.20F;
+      float const hammer_forward =
+          0.18F + std::abs(swing_angle) * 0.15F + body_lean * 0.5F;
+      float const hammer_down =
+          swing_cycle > 0.4F && swing_cycle < 0.65F ? 0.08F : 0.0F;
+
+      QVector3D const hammer_hand(-0.06F + asym,
+                                  hammer_y - hammer_down + torso_y_offset,
+                                  hammer_forward);
+
+      float const brace_y =
+          HP::WAIST_Y + 0.12F + torso_y_offset - crouch_amount * 0.5F;
+      float const brace_forward = 0.15F + body_lean * 0.3F;
+      QVector3D const brace_hand(0.14F - asym * 0.5F, brace_y, brace_forward);
+
+      controller.placeHandAt(true, hammer_hand);
+      controller.placeHandAt(false, brace_hand);
+      return;
+    }
+
+    float const forward = 0.20F + (anim.is_moving ? 0.02F : 0.0F);
+    QVector3D const hammer_hand(-0.12F + asym, HP::WAIST_Y + 0.10F + jitter,
+                                forward + 0.04F);
+
+    QVector3D const rest_hand(0.22F - asym * 0.5F,
+                              HP::WAIST_Y - 0.04F + jitter * 0.5F, 0.10F);
+
+    controller.placeHandAt(true, hammer_hand);
+    controller.placeHandAt(false, rest_hand);
+  }
+
+  void add_attachments(const DrawContext &ctx, const HumanoidVariant &v,
+                       const HumanoidPose &pose,
+                       const HumanoidAnimationContext &anim_ctx,
+                       ISubmitter &out) const override {
+
+    draw_stone_hammer(ctx, v, pose, out);
+  }
+
+  void draw_stone_hammer(const DrawContext &ctx, const HumanoidVariant &v,
+                         const HumanoidPose &pose, ISubmitter &out) const {
+    QVector3D const wood = v.palette.wood;
+
+    QVector3D const stone_color(0.52F, 0.50F, 0.46F);
+    QVector3D const stone_dark(0.42F, 0.40F, 0.36F);
+
+    QVector3D const hand = pose.hand_l;
+    QVector3D const up(0.0F, 1.0F, 0.0F);
+    QVector3D const right(1.0F, 0.0F, 0.0F);
+
+    float const h_len = 0.30F;
+    QVector3D const h_top = hand + up * 0.11F;
+    QVector3D const h_bot = h_top - up * h_len;
+
+    out.mesh(get_unit_cylinder(),
+             cylinder_between(ctx.model, h_bot, h_top, 0.015F), wood, nullptr,
+             1.0F);
+
+    float const head_len = 0.09F;
+    float const head_r = 0.028F;
+    QVector3D const head_center = h_top + up * 0.03F;
+
+    out.mesh(get_unit_cylinder(),
+             cylinder_between(ctx.model,
+                              head_center - right * (head_len * 0.5F),
+                              head_center + right * (head_len * 0.5F), head_r),
+             stone_color, nullptr, 1.0F);
+
+    out.mesh(get_unit_sphere(),
+             sphere_at(ctx.model, head_center + right * (head_len * 0.5F),
+                       head_r * 1.1F),
+             stone_dark, nullptr, 1.0F);
+
+    out.mesh(get_unit_sphere(),
+             sphere_at(ctx.model, head_center - right * (head_len * 0.5F),
+                       head_r * 0.85F),
+             stone_color * 0.92F, nullptr, 1.0F);
+  }
+
+  void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
+                   const HumanoidPose &pose, ISubmitter &out) const override {
+
+    draw_headwrap(ctx, v, pose, out);
+  }
+
+  void draw_headwrap(const DrawContext &ctx, const HumanoidVariant &v,
+                     const HumanoidPose &pose, ISubmitter &out) const {
+    const BodyFrames &frames = pose.body_frames;
+    QVector3D const wrap_color(0.88F, 0.82F, 0.72F);
+
+    QVector3D const head_top = frames.head.origin + frames.head.up * 0.05F;
+    QVector3D const head_back = frames.head.origin -
+                                frames.head.forward * 0.03F +
+                                frames.head.up * 0.02F;
+
+    out.mesh(get_unit_sphere(), sphere_at(ctx.model, head_top, 0.052F),
+             wrap_color, nullptr, 1.0F);
+    out.mesh(get_unit_sphere(), sphere_at(ctx.model, head_back, 0.048F),
+             wrap_color * 0.95F, nullptr, 1.0F);
+  }
+
+  void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
+    uint32_t const seed = reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFU;
+    draw_craftsman_robes(ctx, v, pose, seed, out);
+  }
+
+  void draw_craftsman_robes(const DrawContext &ctx, const HumanoidVariant &v,
+                            const HumanoidPose &pose, uint32_t seed,
+                            ISubmitter &out) const {
+    using HP = HumanProportions;
+    const BodyFrames &frames = pose.body_frames;
+    const AttachmentFrame &torso = frames.torso;
+    const AttachmentFrame &waist = frames.waist;
+
+    if (torso.radius <= 0.0F) {
+      return;
+    }
+
+    float const var = hash_01(seed ^ 0xCDEU);
+    QVector3D robe_color;
+    if (var < 0.35F) {
+      robe_color = QVector3D(0.85F, 0.78F, 0.68F);
+    } else if (var < 0.65F) {
+      robe_color = QVector3D(0.72F, 0.65F, 0.55F);
+    } else {
+      robe_color = QVector3D(0.62F, 0.58F, 0.52F);
+    }
+
+    QVector3D const robe_dark = robe_color * 0.88F;
+
+    const QVector3D &origin = torso.origin;
+    const QVector3D &right = torso.right;
+    const QVector3D &up = torso.up;
+    const QVector3D &forward = torso.forward;
+    float const tr = torso.radius * 1.06F;
+    float const td =
+        (torso.depth > 0.0F) ? torso.depth * 0.90F : torso.radius * 0.78F;
+
+    float const y_sh = origin.y() + 0.035F;
+    float const y_w = waist.origin.y();
+    float const y_hem = y_w - 0.22F;
+
+    constexpr int segs = 12;
+    constexpr float pi = std::numbers::pi_v<float>;
+
+    auto ring = [&](float y, float w, float d, const QVector3D &c, float th) {
+      for (int i = 0; i < segs; ++i) {
+        float a1 = (float(i) / segs) * 2.0F * pi;
+        float a2 = (float(i + 1) / segs) * 2.0F * pi;
+        QVector3D p1 = origin + right * (w * std::sin(a1)) +
+                       forward * (d * std::cos(a1)) + up * (y - origin.y());
+        QVector3D p2 = origin + right * (w * std::sin(a2)) +
+                       forward * (d * std::cos(a2)) + up * (y - origin.y());
+        out.mesh(get_unit_cylinder(), cylinder_between(ctx.model, p1, p2, th),
+                 c, nullptr, 1.0F);
+      }
+    };
+
+    ring(y_sh + 0.045F, tr * 0.65F, td * 0.58F, robe_dark, 0.020F);
+
+    ring(y_sh + 0.03F, tr * 1.15F, td * 1.08F, robe_color, 0.035F);
+    ring(y_sh, tr * 1.10F, td * 1.04F, robe_color, 0.032F);
+
+    for (int i = 0; i < 5; ++i) {
+      float t = float(i) / 4.0F;
+      float y = y_sh - 0.02F - t * (y_sh - y_w - 0.02F);
+      QVector3D c = robe_color * (1.0F - t * 0.05F);
+      ring(y, tr * (1.06F - t * 0.12F), td * (1.00F - t * 0.10F), c,
+           0.026F - t * 0.003F);
+    }
+
+    for (int i = 0; i < 6; ++i) {
+      float t = float(i) / 5.0F;
+      float y = y_w - 0.02F - t * (y_w - y_hem);
+      float flare = 1.0F + t * 0.28F;
+      QVector3D c = robe_color * (1.0F - t * 0.06F);
+      ring(y, tr * 0.85F * flare, td * 0.80F * flare, c, 0.020F + t * 0.008F);
+    }
+
+    auto sleeve = [&](const QVector3D &sh, const QVector3D &out_dir) {
+      QVector3D const back = -forward;
+      QVector3D anchor = sh + up * 0.055F + back * 0.012F;
+      for (int i = 0; i < 4; ++i) {
+        float t = float(i) / 4.0F;
+        QVector3D pos = anchor + out_dir * (0.012F + t * 0.022F) +
+                        forward * (-0.012F + t * 0.05F) - up * (t * 0.035F);
+        float r = HP::UPPER_ARM_R * (1.48F - t * 0.08F);
+        out.mesh(get_unit_sphere(), sphere_at(ctx.model, pos, r),
+                 robe_color * (1.0F - t * 0.03F), nullptr, 1.0F);
+      }
+    };
+    sleeve(frames.shoulder_l.origin, -right);
+    sleeve(frames.shoulder_r.origin, right);
+
+    draw_extended_forearm(ctx, v, pose, out);
+  }
+
+  void draw_extended_forearm(const DrawContext &ctx, const HumanoidVariant &v,
+                             const HumanoidPose &pose, ISubmitter &out) const {
+
+    QVector3D const skin_color = v.palette.skin;
+
+    QVector3D const elbow_r = pose.elbow_r;
+    QVector3D const hand_r = pose.hand_r;
+
+    for (int i = 0; i < 4; ++i) {
+      float t = 0.25F + float(i) * 0.20F;
+      QVector3D pos = elbow_r * (1.0F - t) + hand_r * t;
+      float r = 0.022F - float(i) * 0.002F;
+      out.mesh(get_unit_sphere(), sphere_at(ctx.model, pos, r), skin_color,
+               nullptr, 1.0F);
+    }
+  }
+
+private:
+  auto
+  resolve_style(const DrawContext &ctx) const -> const BuilderStyleConfig & {
+    ensure_builder_styles_registered();
+    auto &styles = style_registry();
+    std::string nation_id;
+    if (ctx.entity != nullptr) {
+      if (auto *unit =
+              ctx.entity->get_component<Engine::Core::UnitComponent>()) {
+        nation_id = Game::Systems::nationIDToString(unit->nation_id);
+      }
+    }
+    if (!nation_id.empty()) {
+      auto it = styles.find(nation_id);
+      if (it != styles.end()) {
+        return it->second;
+      }
+    }
+    auto f = styles.find(std::string(k_default_style_key));
+    if (f != styles.end()) {
+      return f->second;
+    }
+    static const BuilderStyleConfig def{};
+    return def;
+  }
+
+public:
+  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
+    const BuilderStyleConfig &s = resolve_style(ctx);
+    if (!s.shader_id.empty()) {
+      return QString::fromStdString(s.shader_id);
+    }
+    return QStringLiteral("builder");
+  }
+
+private:
+  void apply_palette_overrides(const BuilderStyleConfig &style,
+                               const QVector3D &team_tint,
+                               HumanoidVariant &v) const {
+    auto apply = [&](const std::optional<QVector3D> &c, QVector3D &t, float tw,
+                     float sw) {
+      t = mix_palette_color(t, c, team_tint, tw, sw);
+    };
+    apply(style.skin_color, v.palette.skin, 0.0F, 1.0F);
+    apply(style.cloth_color, v.palette.cloth, 0.0F, 1.0F);
+    apply(style.leather_color, v.palette.leather, k_team_mix_weight,
+          k_style_mix_weight);
+    apply(style.leather_dark_color, v.palette.leatherDark, k_team_mix_weight,
+          k_style_mix_weight);
+    apply(style.metal_color, v.palette.metal, k_team_mix_weight,
+          k_style_mix_weight);
+    apply(style.wood_color, v.palette.wood, k_team_mix_weight,
+          k_style_mix_weight);
+  }
+};
+
+void register_builder_renderer(Render::GL::EntityRendererRegistry &registry) {
+  ensure_builder_styles_registered();
+  static BuilderRenderer const renderer;
+  registry.register_renderer(
+      "troops/carthage/builder", [](const DrawContext &ctx, ISubmitter &out) {
+        static BuilderRenderer const r;
+        Shader *shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString key = r.resolve_shader_key(ctx);
+          shader = ctx.backend->shader(key);
+          if (!shader) {
+            shader = ctx.backend->shader(QStringLiteral("builder"));
+          }
+        }
+        auto *sr = dynamic_cast<Renderer *>(&out);
+        if (sr && shader) {
+          sr->set_current_shader(shader);
+        }
+        r.render(ctx, out);
+        if (sr) {
+          sr->set_current_shader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Carthage

+ 15 - 0
render/entity/nations/carthage/builder_renderer.h

@@ -0,0 +1,15 @@
+#pragma once
+
+#include "../../registry.h"
+#include <string>
+
+namespace Render::GL::Carthage {
+
+struct BuilderStyleConfig;
+
+void register_builder_style(const std::string &nation_id,
+                            const BuilderStyleConfig &style);
+
+void register_builder_renderer(EntityRendererRegistry &registry);
+
+} // namespace Render::GL::Carthage

+ 45 - 0
render/entity/nations/carthage/builder_style.cpp

@@ -0,0 +1,45 @@
+#include "builder_style.h"
+#include "builder_renderer.h"
+
+#include <QVector3D>
+
+namespace {
+
+constexpr QVector3D k_carthage_tunic{0.68F, 0.54F, 0.38F};
+
+constexpr QVector3D k_carthage_skin{0.08F, 0.07F, 0.065F};
+
+constexpr QVector3D k_carthage_leather{0.48F, 0.35F, 0.22F};
+constexpr QVector3D k_carthage_leather_dark{0.32F, 0.24F, 0.16F};
+
+constexpr QVector3D k_carthage_bronze{0.70F, 0.52F, 0.32F};
+
+constexpr QVector3D k_carthage_wood{0.45F, 0.35F, 0.22F};
+
+constexpr QVector3D k_carthage_apron{0.42F, 0.35F, 0.25F};
+} // namespace
+
+namespace Render::GL::Carthage {
+
+void register_carthage_builder_style() {
+  BuilderStyleConfig style;
+  style.cloth_color = k_carthage_tunic;
+  style.skin_color = k_carthage_skin;
+  style.leather_color = k_carthage_leather;
+  style.leather_dark_color = k_carthage_leather_dark;
+  style.metal_color = k_carthage_bronze;
+  style.wood_color = k_carthage_wood;
+  style.apron_color = k_carthage_apron;
+  style.shader_id = "builder_carthage";
+
+  style.show_helmet = false;
+  style.show_armor = false;
+  style.show_tool_belt = true;
+
+  style.force_beard = true;
+
+  register_builder_style("default", style);
+  register_builder_style("carthage", style);
+}
+
+} // namespace Render::GL::Carthage

+ 30 - 0
render/entity/nations/carthage/builder_style.h

@@ -0,0 +1,30 @@
+#pragma once
+
+#include <QVector3D>
+#include <optional>
+#include <string>
+
+namespace Render::GL::Carthage {
+
+struct BuilderStyleConfig {
+  std::optional<QVector3D> cloth_color;
+  std::optional<QVector3D> skin_color;
+  std::optional<QVector3D> leather_color;
+  std::optional<QVector3D> leather_dark_color;
+  std::optional<QVector3D> metal_color;
+  std::optional<QVector3D> wood_color;
+  std::optional<QVector3D> apron_color;
+
+  bool show_helmet = false;
+  bool show_armor = false;
+  bool show_tool_belt = true;
+
+  bool force_beard = true;
+
+  std::string attachment_profile;
+  std::string shader_id;
+};
+
+void register_carthage_builder_style();
+
+} // namespace Render::GL::Carthage

+ 429 - 0
render/entity/nations/roman/builder_renderer.cpp

@@ -0,0 +1,429 @@
+#include "builder_renderer.h"
+#include "../../../../game/core/component.h"
+#include "../../../../game/core/entity.h"
+#include "../../../../game/systems/nation_id.h"
+#include "../../../equipment/equipment_registry.h"
+#include "../../../geom/math_utils.h"
+#include "../../../geom/transforms.h"
+#include "../../../gl/backend.h"
+#include "../../../gl/primitives.h"
+#include "../../../gl/render_constants.h"
+#include "../../../gl/shader.h"
+#include "../../../humanoid/humanoid_math.h"
+#include "../../../humanoid/humanoid_specs.h"
+#include "../../../humanoid/pose_controller.h"
+#include "../../../humanoid/rig.h"
+#include "../../../humanoid/style_palette.h"
+#include "../../../palette.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../registry.h"
+#include "../../renderer_constants.h"
+#include "builder_style.h"
+
+#include <QMatrix4x4>
+#include <QString>
+#include <QVector3D>
+#include <cmath>
+#include <cstdint>
+#include <numbers>
+#include <optional>
+#include <qmatrix4x4.h>
+#include <qstringliteral.h>
+#include <qvectornd.h>
+#include <string>
+#include <string_view>
+#include <unordered_map>
+
+using Render::Geom::cylinder_between;
+using Render::Geom::sphere_at;
+
+namespace Render::GL::Roman {
+
+namespace {
+
+constexpr std::string_view k_default_style_key = "default";
+
+auto style_registry() -> std::unordered_map<std::string, BuilderStyleConfig> & {
+  static std::unordered_map<std::string, BuilderStyleConfig> styles;
+  return styles;
+}
+
+void ensure_builder_styles_registered() {
+  static const bool registered = []() {
+    register_roman_builder_style();
+    return true;
+  }();
+  (void)registered;
+}
+
+constexpr float k_team_mix_weight = 0.65F;
+constexpr float k_style_mix_weight = 0.35F;
+
+} // namespace
+
+void register_builder_style(const std::string &nation_id,
+                            const BuilderStyleConfig &style) {
+  style_registry()[nation_id] = style;
+}
+
+using Render::Geom::clamp01;
+using Render::Geom::clampf;
+using Render::GL::Humanoid::mix_palette_color;
+using Render::GL::Humanoid::saturate_color;
+
+class BuilderRenderer : public HumanoidRendererBase {
+public:
+  auto get_proportion_scaling() const -> QVector3D override {
+    return {1.05F, 0.98F, 1.02F};
+  }
+
+  void get_variant(const DrawContext &ctx, uint32_t seed,
+                   HumanoidVariant &v) const override {
+    QVector3D const team_tint = resolve_team_tint(ctx);
+    v.palette = make_humanoid_palette(team_tint, seed);
+    auto const &style = resolve_style(ctx);
+    apply_palette_overrides(style, team_tint, v);
+  }
+
+  void customize_pose(const DrawContext &,
+                      const HumanoidAnimationContext &anim_ctx, uint32_t seed,
+                      HumanoidPose &pose) const override {
+    using HP = HumanProportions;
+
+    const AnimationInputs &anim = anim_ctx.inputs;
+    HumanoidPoseController controller(pose, anim_ctx);
+
+    float const arm_jitter = (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.04F;
+    float const asymmetry = (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.05F;
+
+    if (anim.is_constructing) {
+
+      float const phase_offset = float(seed % 100) * 0.0628F;
+      float const cycle_speed = 2.0F + float(seed % 50) * 0.02F;
+      float const swing_cycle =
+          std::fmod(anim.time * cycle_speed + phase_offset, 1.0F);
+
+      float swing_angle;
+      float body_lean;
+      float crouch_amount;
+
+      if (swing_cycle < 0.3F) {
+
+        float const t = swing_cycle / 0.3F;
+        swing_angle = t * 0.85F;
+        body_lean = -t * 0.08F;
+        crouch_amount = 0.0F;
+      } else if (swing_cycle < 0.5F) {
+
+        float const t = (swing_cycle - 0.3F) / 0.2F;
+        swing_angle = 0.85F - t * 1.3F;
+        body_lean = -0.08F + t * 0.22F;
+        crouch_amount = t * 0.06F;
+      } else if (swing_cycle < 0.6F) {
+
+        float const t = (swing_cycle - 0.5F) / 0.1F;
+        swing_angle = -0.45F + t * 0.15F;
+        body_lean = 0.14F - t * 0.04F;
+        crouch_amount = 0.06F - t * 0.02F;
+      } else {
+
+        float const t = (swing_cycle - 0.6F) / 0.4F;
+        swing_angle = -0.30F + t * 0.30F;
+        body_lean = 0.10F * (1.0F - t);
+        crouch_amount = 0.04F * (1.0F - t);
+      }
+
+      float const torso_y_offset = -crouch_amount;
+
+      float const hammer_y = HP::SHOULDER_Y + 0.10F + swing_angle * 0.20F;
+      float const hammer_forward =
+          0.18F + std::abs(swing_angle) * 0.15F + body_lean * 0.5F;
+      float const hammer_down =
+          swing_cycle > 0.4F && swing_cycle < 0.65F ? 0.08F : 0.0F;
+
+      QVector3D const hammer_hand(-0.06F + asymmetry,
+                                  hammer_y - hammer_down + torso_y_offset,
+                                  hammer_forward);
+
+      float const brace_y =
+          HP::WAIST_Y + 0.12F + torso_y_offset - crouch_amount * 0.5F;
+      float const brace_forward = 0.15F + body_lean * 0.3F;
+      QVector3D const brace_hand(0.14F - asymmetry * 0.5F, brace_y,
+                                 brace_forward);
+
+      controller.placeHandAt(true, hammer_hand);
+      controller.placeHandAt(false, brace_hand);
+      return;
+    }
+
+    float const hammer_hand_forward = 0.22F + (anim.is_moving ? 0.03F : 0.0F);
+    float const hammer_hand_height = HP::WAIST_Y + 0.08F + arm_jitter;
+
+    QVector3D const hammer_hand(-0.10F + asymmetry, hammer_hand_height + 0.04F,
+                                hammer_hand_forward);
+
+    QVector3D const rest_hand(0.24F - asymmetry * 0.5F,
+                              HP::WAIST_Y - 0.02F + arm_jitter * 0.5F, 0.08F);
+
+    controller.placeHandAt(true, hammer_hand);
+    controller.placeHandAt(false, rest_hand);
+  }
+
+  void add_attachments(const DrawContext &ctx, const HumanoidVariant &v,
+                       const HumanoidPose &pose,
+                       const HumanoidAnimationContext &anim_ctx,
+                       ISubmitter &out) const override {
+
+    draw_stone_hammer(ctx, v, pose, out);
+  }
+
+  void draw_stone_hammer(const DrawContext &ctx, const HumanoidVariant &v,
+                         const HumanoidPose &pose, ISubmitter &out) const {
+    QVector3D const wood_color = v.palette.wood;
+
+    QVector3D const stone_color(0.55F, 0.52F, 0.48F);
+    QVector3D const stone_dark(0.45F, 0.42F, 0.38F);
+
+    QVector3D const hand = pose.hand_l;
+    QVector3D const up(0.0F, 1.0F, 0.0F);
+    QVector3D const forward(0.0F, 0.0F, 1.0F);
+    QVector3D const right(1.0F, 0.0F, 0.0F);
+
+    float const handle_len = 0.32F;
+    float const handle_r = 0.016F;
+    QVector3D const handle_top = hand + up * 0.12F + forward * 0.02F;
+    QVector3D const handle_bot = handle_top - up * handle_len;
+
+    out.mesh(get_unit_cylinder(),
+             cylinder_between(ctx.model, handle_bot, handle_top, handle_r),
+             wood_color, nullptr, 1.0F);
+
+    float const head_len = 0.10F;
+    float const head_r = 0.030F;
+    QVector3D const head_center = handle_top + up * 0.035F;
+
+    out.mesh(get_unit_cylinder(),
+             cylinder_between(ctx.model,
+                              head_center - right * (head_len * 0.5F),
+                              head_center + right * (head_len * 0.5F), head_r),
+             stone_color, nullptr, 1.0F);
+
+    out.mesh(get_unit_sphere(),
+             sphere_at(ctx.model, head_center + right * (head_len * 0.5F),
+                       head_r * 1.15F),
+             stone_dark, nullptr, 1.0F);
+
+    out.mesh(get_unit_sphere(),
+             sphere_at(ctx.model, head_center - right * (head_len * 0.5F),
+                       head_r * 0.9F),
+             stone_color * 0.95F, nullptr, 1.0F);
+  }
+
+  void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
+                   const HumanoidPose &pose, ISubmitter &out) const override {
+
+    auto &registry = EquipmentRegistry::instance();
+    auto helmet = registry.get(EquipmentCategory::Helmet, "roman_light");
+    if (helmet) {
+      HumanoidAnimationContext anim_ctx{};
+      helmet->render(ctx, pose.body_frames, v.palette, anim_ctx, out);
+    }
+  }
+
+  void draw_armor(const DrawContext &ctx, const HumanoidVariant &v,
+                  const HumanoidPose &pose,
+                  const HumanoidAnimationContext &anim,
+                  ISubmitter &out) const override {
+    uint32_t const seed = reinterpret_cast<uintptr_t>(ctx.entity) & 0xFFFFFFFFU;
+    draw_work_tunic(ctx, v, pose, seed, out);
+  }
+
+  void draw_work_tunic(const DrawContext &ctx, const HumanoidVariant &v,
+                       const HumanoidPose &pose, uint32_t seed,
+                       ISubmitter &out) const {
+    using HP = HumanProportions;
+    const BodyFrames &frames = pose.body_frames;
+    const AttachmentFrame &torso = frames.torso;
+    const AttachmentFrame &waist = frames.waist;
+
+    if (torso.radius <= 0.0F) {
+      return;
+    }
+
+    float const color_var = hash_01(seed ^ 0xABCU);
+    QVector3D tunic_base;
+    if (color_var < 0.4F) {
+      tunic_base = QVector3D(0.65F, 0.52F, 0.38F);
+    } else if (color_var < 0.7F) {
+      tunic_base = QVector3D(0.58F, 0.48F, 0.35F);
+    } else {
+      tunic_base = QVector3D(0.72F, 0.62F, 0.48F);
+    }
+
+    QVector3D const tunic_dark = tunic_base * 0.85F;
+
+    const QVector3D &origin = torso.origin;
+    const QVector3D &right = torso.right;
+    const QVector3D &up = torso.up;
+    const QVector3D &forward = torso.forward;
+    float const torso_r = torso.radius * 1.08F;
+    float const torso_d =
+        (torso.depth > 0.0F) ? torso.depth * 0.92F : torso.radius * 0.80F;
+
+    float const y_shoulder = origin.y() + 0.032F;
+    float const y_waist = waist.origin.y();
+    float const y_hem = y_waist - 0.16F;
+
+    constexpr int segs = 12;
+    constexpr float pi = std::numbers::pi_v<float>;
+
+    auto drawRing = [&](float y, float w, float d, const QVector3D &col,
+                        float th) {
+      for (int i = 0; i < segs; ++i) {
+        float a1 = (float(i) / segs) * 2.0F * pi;
+        float a2 = (float(i + 1) / segs) * 2.0F * pi;
+        QVector3D p1 = origin + right * (w * std::sin(a1)) +
+                       forward * (d * std::cos(a1)) + up * (y - origin.y());
+        QVector3D p2 = origin + right * (w * std::sin(a2)) +
+                       forward * (d * std::cos(a2)) + up * (y - origin.y());
+        out.mesh(get_unit_cylinder(), cylinder_between(ctx.model, p1, p2, th),
+                 col, nullptr, 1.0F);
+      }
+    };
+
+    drawRing(y_shoulder + 0.04F, torso_r * 0.68F, torso_d * 0.60F, tunic_dark,
+             0.022F);
+
+    drawRing(y_shoulder + 0.02F, torso_r * 1.08F, torso_d * 1.02F, tunic_base,
+             0.032F);
+
+    for (int i = 0; i < 5; ++i) {
+      float t = float(i) / 4.0F;
+      float y = y_shoulder - 0.01F - t * (y_shoulder - y_waist - 0.03F);
+      float w = torso_r * (1.04F - t * 0.14F);
+      float d = torso_d * (0.98F - t * 0.10F);
+      QVector3D col = tunic_base * (1.0F - t * 0.06F);
+      drawRing(y, w, d, col, 0.026F - t * 0.004F);
+    }
+
+    for (int i = 0; i < 4; ++i) {
+      float t = float(i) / 3.0F;
+      float y = y_waist - 0.01F - t * (y_waist - y_hem);
+      float flare = 1.0F + t * 0.18F;
+      QVector3D col = tunic_base * (1.0F - t * 0.08F);
+      drawRing(y, torso_r * 0.80F * flare, torso_d * 0.76F * flare, col,
+               0.018F + t * 0.006F);
+    }
+
+    auto drawSleeve = [&](const QVector3D &shoulder, const QVector3D &out_dir,
+                          const QVector3D &elbow) {
+      for (int i = 0; i < 3; ++i) {
+        float t = float(i) / 3.0F;
+        QVector3D pos =
+            shoulder * (1.0F - t) + elbow * t * 0.6F + out_dir * 0.008F;
+        float r = HP::UPPER_ARM_R * (1.40F - t * 0.25F);
+        out.mesh(get_unit_sphere(), sphere_at(ctx.model, pos, r),
+                 tunic_base * (1.0F - t * 0.04F), nullptr, 1.0F);
+      }
+    };
+    drawSleeve(frames.shoulder_l.origin, -right, pose.elbow_l);
+    drawSleeve(frames.shoulder_r.origin, right, pose.elbow_r);
+
+    draw_extended_forearm(ctx, v, pose, out);
+  }
+
+  void draw_extended_forearm(const DrawContext &ctx, const HumanoidVariant &v,
+                             const HumanoidPose &pose, ISubmitter &out) const {
+
+    QVector3D const skin_color = v.palette.skin;
+
+    QVector3D const elbow_r = pose.elbow_r;
+    QVector3D const hand_r = pose.hand_r;
+
+    for (int i = 0; i < 4; ++i) {
+      float t = 0.25F + float(i) * 0.20F;
+      QVector3D pos = elbow_r * (1.0F - t) + hand_r * t;
+      float r = 0.024F - float(i) * 0.002F;
+      out.mesh(get_unit_sphere(), sphere_at(ctx.model, pos, r), skin_color,
+               nullptr, 1.0F);
+    }
+  }
+
+private:
+  auto
+  resolve_style(const DrawContext &ctx) const -> const BuilderStyleConfig & {
+    ensure_builder_styles_registered();
+    auto &styles = style_registry();
+    std::string nation_id;
+    if (ctx.entity != nullptr) {
+      if (auto *unit =
+              ctx.entity->get_component<Engine::Core::UnitComponent>()) {
+        nation_id = Game::Systems::nationIDToString(unit->nation_id);
+      }
+    }
+    if (!nation_id.empty()) {
+      auto it = styles.find(nation_id);
+      if (it != styles.end()) {
+        return it->second;
+      }
+    }
+    auto fallback = styles.find(std::string(k_default_style_key));
+    if (fallback != styles.end()) {
+      return fallback->second;
+    }
+    static const BuilderStyleConfig default_style{};
+    return default_style;
+  }
+
+public:
+  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
+    const BuilderStyleConfig &style = resolve_style(ctx);
+    if (!style.shader_id.empty()) {
+      return QString::fromStdString(style.shader_id);
+    }
+    return QStringLiteral("builder");
+  }
+
+private:
+  void apply_palette_overrides(const BuilderStyleConfig &style,
+                               const QVector3D &team_tint,
+                               HumanoidVariant &variant) const {
+    auto apply = [&](const std::optional<QVector3D> &c, QVector3D &t) {
+      t = mix_palette_color(t, c, team_tint, k_team_mix_weight,
+                            k_style_mix_weight);
+    };
+    apply(style.cloth_color, variant.palette.cloth);
+    apply(style.leather_color, variant.palette.leather);
+    apply(style.leather_dark_color, variant.palette.leatherDark);
+    apply(style.metal_color, variant.palette.metal);
+    apply(style.wood_color, variant.palette.wood);
+  }
+};
+
+void register_builder_renderer(Render::GL::EntityRendererRegistry &registry) {
+  ensure_builder_styles_registered();
+  static BuilderRenderer const renderer;
+  registry.register_renderer(
+      "troops/roman/builder", [](const DrawContext &ctx, ISubmitter &out) {
+        static BuilderRenderer const r;
+        Shader *shader = nullptr;
+        if (ctx.backend != nullptr) {
+          QString key = r.resolve_shader_key(ctx);
+          shader = ctx.backend->shader(key);
+          if (!shader) {
+            shader = ctx.backend->shader(QStringLiteral("builder"));
+          }
+        }
+        auto *sr = dynamic_cast<Renderer *>(&out);
+        if (sr && shader) {
+          sr->set_current_shader(shader);
+        }
+        r.render(ctx, out);
+        if (sr) {
+          sr->set_current_shader(nullptr);
+        }
+      });
+}
+
+} // namespace Render::GL::Roman

+ 15 - 0
render/entity/nations/roman/builder_renderer.h

@@ -0,0 +1,15 @@
+#pragma once
+
+#include "../../registry.h"
+#include <string>
+
+namespace Render::GL::Roman {
+
+struct BuilderStyleConfig;
+
+void register_builder_style(const std::string &nation_id,
+                            const BuilderStyleConfig &style);
+
+void register_builder_renderer(EntityRendererRegistry &registry);
+
+} // namespace Render::GL::Roman

+ 40 - 0
render/entity/nations/roman/builder_style.cpp

@@ -0,0 +1,40 @@
+#include "builder_style.h"
+#include "builder_renderer.h"
+
+#include <QVector3D>
+
+namespace {
+
+constexpr QVector3D k_roman_tunic{0.72F, 0.58F, 0.42F};
+
+constexpr QVector3D k_roman_leather{0.55F, 0.42F, 0.30F};
+constexpr QVector3D k_roman_leather_dark{0.35F, 0.28F, 0.20F};
+
+constexpr QVector3D k_roman_bronze{0.72F, 0.55F, 0.35F};
+
+constexpr QVector3D k_roman_wood{0.52F, 0.42F, 0.28F};
+
+constexpr QVector3D k_roman_apron{0.45F, 0.38F, 0.28F};
+} // namespace
+
+namespace Render::GL::Roman {
+
+void register_roman_builder_style() {
+  BuilderStyleConfig style;
+  style.cloth_color = k_roman_tunic;
+  style.leather_color = k_roman_leather;
+  style.leather_dark_color = k_roman_leather_dark;
+  style.metal_color = k_roman_bronze;
+  style.wood_color = k_roman_wood;
+  style.apron_color = k_roman_apron;
+  style.shader_id = "builder_roman_republic";
+
+  style.show_helmet = false;
+  style.show_armor = false;
+  style.show_tool_belt = true;
+
+  register_builder_style("default", style);
+  register_builder_style("roman_republic", style);
+}
+
+} // namespace Render::GL::Roman

+ 27 - 0
render/entity/nations/roman/builder_style.h

@@ -0,0 +1,27 @@
+#pragma once
+
+#include <QVector3D>
+#include <optional>
+#include <string>
+
+namespace Render::GL::Roman {
+
+struct BuilderStyleConfig {
+  std::optional<QVector3D> cloth_color;
+  std::optional<QVector3D> leather_color;
+  std::optional<QVector3D> leather_dark_color;
+  std::optional<QVector3D> metal_color;
+  std::optional<QVector3D> wood_color;
+  std::optional<QVector3D> apron_color;
+
+  bool show_helmet = false;
+  bool show_armor = false;
+  bool show_tool_belt = true;
+
+  std::string attachment_profile;
+  std::string shader_id;
+};
+
+void register_roman_builder_style();
+
+} // namespace Render::GL::Roman

+ 5 - 0
render/entity/registry.cpp

@@ -6,6 +6,7 @@
 #include "defense_tower_renderer.h"
 #include "defense_tower_renderer.h"
 #include "nations/carthage/archer_renderer.h"
 #include "nations/carthage/archer_renderer.h"
 #include "nations/carthage/ballista_renderer.h"
 #include "nations/carthage/ballista_renderer.h"
+#include "nations/carthage/builder_renderer.h"
 #include "nations/carthage/catapult_renderer.h"
 #include "nations/carthage/catapult_renderer.h"
 #include "nations/carthage/healer_renderer.h"
 #include "nations/carthage/healer_renderer.h"
 #include "nations/carthage/horse_archer_renderer.h"
 #include "nations/carthage/horse_archer_renderer.h"
@@ -15,6 +16,7 @@
 #include "nations/carthage/swordsman_renderer.h"
 #include "nations/carthage/swordsman_renderer.h"
 #include "nations/roman/archer_renderer.h"
 #include "nations/roman/archer_renderer.h"
 #include "nations/roman/ballista_renderer.h"
 #include "nations/roman/ballista_renderer.h"
+#include "nations/roman/builder_renderer.h"
 #include "nations/roman/catapult_renderer.h"
 #include "nations/roman/catapult_renderer.h"
 #include "nations/roman/healer_renderer.h"
 #include "nations/roman/healer_renderer.h"
 #include "nations/roman/horse_archer_renderer.h"
 #include "nations/roman/horse_archer_renderer.h"
@@ -62,6 +64,9 @@ void registerBuiltInEntityRenderers(EntityRendererRegistry &registry) {
   Roman::register_healer_renderer(registry);
   Roman::register_healer_renderer(registry);
   Carthage::register_healer_renderer(registry);
   Carthage::register_healer_renderer(registry);
 
 
+  Roman::register_builder_renderer(registry);
+  Carthage::register_builder_renderer(registry);
+
   register_catapult_renderer(registry);
   register_catapult_renderer(registry);
 
 
   register_ballista_renderer(registry);
   register_ballista_renderer(registry);

+ 10 - 0
render/gl/humanoid/animation/animation_inputs.cpp

@@ -86,6 +86,16 @@ auto sample_anim_state(const DrawContext &ctx) -> AnimationInputs {
     anim.healing_target_dz = healer->healing_target_z - transform->position.z;
     anim.healing_target_dz = healer->healing_target_z - transform->position.z;
   }
   }
 
 
+  auto *builder_prod =
+      ctx.entity->get_component<Engine::Core::BuilderProductionComponent>();
+  if (builder_prod != nullptr && builder_prod->in_progress) {
+    anim.is_constructing = true;
+    if (builder_prod->build_time > 0.0F) {
+      anim.construction_progress =
+          1.0F - (builder_prod->time_remaining / builder_prod->build_time);
+    }
+  }
+
   if (combat_state != nullptr) {
   if (combat_state != nullptr) {
     anim.combat_phase =
     anim.combat_phase =
         map_combat_state_to_phase(combat_state->animation_state);
         map_combat_state_to_phase(combat_state->animation_state);

+ 2 - 0
render/gl/humanoid/humanoid_types.h

@@ -32,6 +32,8 @@ struct AnimationInputs {
   bool is_healing{false};
   bool is_healing{false};
   float healing_target_dx{0.0F};
   float healing_target_dx{0.0F};
   float healing_target_dz{0.0F};
   float healing_target_dz{0.0F};
+  bool is_constructing{false};
+  float construction_progress{0.0F};
 };
 };
 
 
 struct FormationParams {
 struct FormationParams {

+ 35 - 0
render/humanoid/formation_calculator.cpp

@@ -105,13 +105,44 @@ auto CarthageCavalryFormation::calculateOffset(
   return {offset_x, offset_z};
   return {offset_x, offset_z};
 }
 }
 
 
+auto BuilderCircleFormation::calculateOffset(
+    int idx, int row, int col, int rows, int cols, float spacing,
+    uint32_t seed) const -> FormationOffset {
+
+  int const total_units = rows * cols;
+  float const angle =
+      (float(idx) / float(total_units)) * 2.0F * 3.14159265358979F;
+  float const radius = spacing * 1.8F;
+
+  float offset_x = std::cos(angle) * radius;
+  float offset_z = std::sin(angle) * radius;
+
+  uint32_t rng_state = seed ^ (uint32_t(idx) * 2654435761U);
+  auto fast_random = [](uint32_t &state) -> float {
+    state = state * 1664525U + 1013904223U;
+    return float(state & 0x7FFFFFU) / float(0x7FFFFFU);
+  };
+
+  float const jitter = spacing * 0.08F;
+  offset_x += (fast_random(rng_state) - 0.5F) * jitter;
+  offset_z += (fast_random(rng_state) - 0.5F) * jitter;
+
+  return {offset_x, offset_z};
+}
+
 RomanInfantryFormation FormationCalculatorFactory::s_romanInfantry;
 RomanInfantryFormation FormationCalculatorFactory::s_romanInfantry;
 RomanCavalryFormation FormationCalculatorFactory::s_romanCavalry;
 RomanCavalryFormation FormationCalculatorFactory::s_romanCavalry;
 CarthageInfantryFormation FormationCalculatorFactory::s_carthageInfantry;
 CarthageInfantryFormation FormationCalculatorFactory::s_carthageInfantry;
 CarthageCavalryFormation FormationCalculatorFactory::s_carthageCavalry;
 CarthageCavalryFormation FormationCalculatorFactory::s_carthageCavalry;
+BuilderCircleFormation FormationCalculatorFactory::s_builderCircle;
 
 
 auto FormationCalculatorFactory::getCalculator(
 auto FormationCalculatorFactory::getCalculator(
     Nation nation, UnitCategory category) -> const IFormationCalculator * {
     Nation nation, UnitCategory category) -> const IFormationCalculator * {
+
+  if (category == UnitCategory::BuilderConstruction) {
+    return &s_builderCircle;
+  }
+
   switch (nation) {
   switch (nation) {
   case Nation::Roman:
   case Nation::Roman:
     switch (category) {
     switch (category) {
@@ -119,6 +150,8 @@ auto FormationCalculatorFactory::getCalculator(
       return &s_romanInfantry;
       return &s_romanInfantry;
     case UnitCategory::Cavalry:
     case UnitCategory::Cavalry:
       return &s_romanCavalry;
       return &s_romanCavalry;
+    case UnitCategory::BuilderConstruction:
+      return &s_builderCircle;
     }
     }
     break;
     break;
 
 
@@ -128,6 +161,8 @@ auto FormationCalculatorFactory::getCalculator(
       return &s_carthageInfantry;
       return &s_carthageInfantry;
     case UnitCategory::Cavalry:
     case UnitCategory::Cavalry:
       return &s_carthageCavalry;
       return &s_carthageCavalry;
+    case UnitCategory::BuilderConstruction:
+      return &s_builderCircle;
     }
     }
     break;
     break;
   }
   }

+ 13 - 1
render/humanoid/formation_calculator.h

@@ -67,11 +67,22 @@ public:
   }
   }
 };
 };
 
 
+class BuilderCircleFormation : public IFormationCalculator {
+public:
+  [[nodiscard]] auto
+  calculateOffset(int idx, int row, int col, int rows, int cols, float spacing,
+                  uint32_t seed) const -> FormationOffset override;
+
+  [[nodiscard]] auto get_description() const -> const char * override {
+    return "Builder Circle (Construction)";
+  }
+};
+
 class FormationCalculatorFactory {
 class FormationCalculatorFactory {
 public:
 public:
   enum class Nation { Roman, Carthage };
   enum class Nation { Roman, Carthage };
 
 
-  enum class UnitCategory { Infantry, Cavalry };
+  enum class UnitCategory { Infantry, Cavalry, BuilderConstruction };
 
 
   static auto getCalculator(Nation nation, UnitCategory category)
   static auto getCalculator(Nation nation, UnitCategory category)
       -> const IFormationCalculator *;
       -> const IFormationCalculator *;
@@ -81,6 +92,7 @@ private:
   static RomanCavalryFormation s_romanCavalry;
   static RomanCavalryFormation s_romanCavalry;
   static CarthageInfantryFormation s_carthageInfantry;
   static CarthageInfantryFormation s_carthageInfantry;
   static CarthageCavalryFormation s_carthageCavalry;
   static CarthageCavalryFormation s_carthageCavalry;
+  static BuilderCircleFormation s_builderCircle;
 };
 };
 
 
 } // namespace Render::GL
 } // namespace Render::GL

+ 5 - 0
ui/qml/HUDBottom.qml

@@ -573,6 +573,11 @@ RowLayout {
                 game.start_building_placement("defense_tower");
                 game.start_building_placement("defense_tower");
 
 
         }
         }
+        onBuilderConstruction: function(itemType) {
+            if (typeof game !== 'undefined' && game.start_builder_construction)
+                game.start_builder_construction(itemType);
+
+        }
     }
     }
 
 
 }
 }

+ 1 - 1
ui/qml/LoadScreen.qml

@@ -8,7 +8,7 @@ Rectangle {
     property bool is_loading: false
     property bool is_loading: false
     property string stage_text: "Loading..."
     property string stage_text: "Loading..."
     property bool use_real_progress: true
     property bool use_real_progress: true
-    property var _bgSources: ["qrc:/qt/qml/StandardOfIron/assets/visuals/load_screen.png", "qrc:/StandardOfIron/assets/visuals/load_screen.png", "qrc:/assets/visuals/load_screen.png", "assets/visuals/load_screen.png"]
+    property var _bgSources: ["qrc:/StandardOfIron/assets/visuals/load_screen.png", "qrc:/assets/visuals/load_screen.png", "assets/visuals/load_screen.png", "qrc:/qt/qml/StandardOfIron/assets/visuals/load_screen.png"]
     property int _bgIndex: 0
     property int _bgIndex: 0
 
 
     function complete_loading() {
     function complete_loading() {

+ 344 - 166
ui/qml/ProductionPanel.qml

@@ -12,6 +12,7 @@ Rectangle {
     signal recruitUnit(string unitType)
     signal recruitUnit(string unitType)
     signal rallyModeToggled()
     signal rallyModeToggled()
     signal buildTower()
     signal buildTower()
+    signal builderConstruction(string itemType)
 
 
     function defaultProductionState() {
     function defaultProductionState() {
         return {
         return {
@@ -953,114 +954,8 @@ Rectangle {
                         Rectangle {
                         Rectangle {
                             property int queueTotal: (unitGridContent.prod.in_progress ? 1 : 0) + (unitGridContent.prod.queue_size || 0)
                             property int queueTotal: (unitGridContent.prod.in_progress ? 1 : 0) + (unitGridContent.prod.queue_size || 0)
                             property bool isEnabled: unitGridContent.prod.has_barracks && unitGridContent.prod.produced_count < unitGridContent.prod.max_units && queueTotal < 5
                             property bool isEnabled: unitGridContent.prod.has_barracks && unitGridContent.prod.produced_count < unitGridContent.prod.max_units && queueTotal < 5
-                            property var unitInfo: productionPanel.getUnitProductionInfo("catapult")
-                            property bool isHovered: catapultMouseArea.containsMouse
-
-                            width: 110
-                            height: 80
-                            radius: 6
-                            color: isEnabled ? (isHovered ? "#1f8dd9" : "#2c3e50") : "#1a1a1a"
-                            border.color: isEnabled ? (isHovered ? "#00d4ff" : "#4a6572") : "#2a2a2a"
-                            border.width: isHovered && isEnabled ? 4 : 2
-                            opacity: isEnabled ? 1 : 0.5
-                            scale: isHovered && isEnabled ? 1.1 : 1
-
-                            Image {
-                                id: catapultRecruitIcon
-
-                                anchors.fill: parent
-                                fillMode: Image.PreserveAspectCrop
-                                smooth: true
-                                source: productionPanel.unitIconSource("catapult", unitGridContent.prod.nation_id)
-                                visible: source !== ""
-                                opacity: parent.isEnabled ? 1 : 0.35
-                            }
-
-                            Text {
-                                anchors.centerIn: parent
-                                visible: !catapultRecruitIcon.visible
-                                text: productionPanel.unitIconEmoji("catapult")
-                                color: parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
-                                font.pointSize: 42
-                                opacity: parent.isEnabled ? 0.9 : 0.4
-                            }
-
-                            Rectangle {
-                                id: catapultCostBadge
-
-                                width: catapultCostText.implicitWidth + 12
-                                height: catapultCostText.implicitHeight + 6
-                                anchors.horizontalCenter: parent.horizontalCenter
-                                anchors.bottom: parent.bottom
-                                anchors.bottomMargin: 6
-                                radius: 8
-                                color: parent.isEnabled ? "#000000b3" : "#00000066"
-                                border.color: parent.isEnabled ? "#f39c12" : "#555555"
-                                border.width: 1
-
-                                Text {
-                                    id: catapultCostText
-
-                                    anchors.centerIn: parent
-                                    text: parent.parent.unitInfo.cost || 200
-                                    color: catapultCostBadge.parent.isEnabled ? "#fdf7e3" : "#8a8a8a"
-                                    font.pointSize: 16
-                                    font.bold: true
-                                }
-
-                            }
-
-                            MouseArea {
-                                id: catapultMouseArea
-
-                                anchors.fill: parent
-                                hoverEnabled: true
-                                onClicked: {
-                                    if (parent.isEnabled)
-                                        productionPanel.recruitUnit("catapult");
-
-                                }
-                                cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
-                                ToolTip.visible: containsMouse
-                                ToolTip.text: parent.isEnabled ? qsTr("Recruit Catapult\nCost: %1 villagers\nBuild time: %2s").arg(parent.unitInfo.cost || 200).arg((parent.unitInfo.build_time || 12).toFixed(0)) : (parent.queueTotal >= 5 ? qsTr("Queue is full (5/5)") : (unitGridContent.prod.produced_count >= unitGridContent.prod.max_units ? qsTr("Unit cap reached") : qsTr("Cannot recruit")))
-                                ToolTip.delay: 300
-                            }
-
-                            Rectangle {
-                                anchors.fill: parent
-                                color: "#ffffff"
-                                opacity: catapultMouseArea.pressed ? 0.2 : 0
-                                radius: parent.radius
-                            }
-
-                            Behavior on color {
-                                ColorAnimation {
-                                    duration: 150
-                                }
-
-                            }
-
-                            Behavior on border.color {
-                                ColorAnimation {
-                                    duration: 150
-                                }
-
-                            }
-
-                            Behavior on scale {
-                                NumberAnimation {
-                                    duration: 100
-                                }
-
-                            }
-
-                        }
-
-                        Rectangle {
-                            property int queueTotal: (unitGridContent.prod.in_progress ? 1 : 0) + (unitGridContent.prod.queue_size || 0)
-                            property bool isEnabled: unitGridContent.prod.has_barracks && unitGridContent.prod.produced_count < unitGridContent.prod.max_units && queueTotal < 5
-                            property var unitInfo: productionPanel.getUnitProductionInfo("ballista")
-                            property bool isHovered: ballistaMouseArea.containsMouse
+                            property var unitInfo: productionPanel.getUnitProductionInfo("healer")
+                            property bool isHovered: healerMouseArea.containsMouse
 
 
                             width: 110
                             width: 110
                             height: 80
                             height: 80
@@ -1072,30 +967,30 @@ Rectangle {
                             scale: isHovered && isEnabled ? 1.1 : 1
                             scale: isHovered && isEnabled ? 1.1 : 1
 
 
                             Image {
                             Image {
-                                id: ballistaRecruitIcon
+                                id: healerRecruitIcon
 
 
                                 anchors.fill: parent
                                 anchors.fill: parent
                                 fillMode: Image.PreserveAspectCrop
                                 fillMode: Image.PreserveAspectCrop
                                 smooth: true
                                 smooth: true
-                                source: productionPanel.unitIconSource("ballista", unitGridContent.prod.nation_id)
+                                source: productionPanel.unitIconSource("healer", unitGridContent.prod.nation_id)
                                 visible: source !== ""
                                 visible: source !== ""
                                 opacity: parent.isEnabled ? 1 : 0.35
                                 opacity: parent.isEnabled ? 1 : 0.35
                             }
                             }
 
 
                             Text {
                             Text {
                                 anchors.centerIn: parent
                                 anchors.centerIn: parent
-                                visible: !ballistaRecruitIcon.visible
-                                text: productionPanel.unitIconEmoji("ballista")
+                                visible: !healerRecruitIcon.visible
+                                text: productionPanel.unitIconEmoji("healer")
                                 color: parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
                                 color: parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
                                 font.pointSize: 42
                                 font.pointSize: 42
                                 opacity: parent.isEnabled ? 0.9 : 0.4
                                 opacity: parent.isEnabled ? 0.9 : 0.4
                             }
                             }
 
 
                             Rectangle {
                             Rectangle {
-                                id: ballistaCostBadge
+                                id: healerCostBadge
 
 
-                                width: ballistaCostText.implicitWidth + 12
-                                height: ballistaCostText.implicitHeight + 6
+                                width: healerCostText.implicitWidth + 12
+                                height: healerCostText.implicitHeight + 6
                                 anchors.horizontalCenter: parent.horizontalCenter
                                 anchors.horizontalCenter: parent.horizontalCenter
                                 anchors.bottom: parent.bottom
                                 anchors.bottom: parent.bottom
                                 anchors.bottomMargin: 6
                                 anchors.bottomMargin: 6
@@ -1105,11 +1000,11 @@ Rectangle {
                                 border.width: 1
                                 border.width: 1
 
 
                                 Text {
                                 Text {
-                                    id: ballistaCostText
+                                    id: healerCostText
 
 
                                     anchors.centerIn: parent
                                     anchors.centerIn: parent
-                                    text: parent.parent.unitInfo.cost || 180
-                                    color: ballistaCostBadge.parent.isEnabled ? "#fdf7e3" : "#8a8a8a"
+                                    text: parent.parent.unitInfo.cost || 100
+                                    color: healerCostBadge.parent.isEnabled ? "#fdf7e3" : "#8a8a8a"
                                     font.pointSize: 16
                                     font.pointSize: 16
                                     font.bold: true
                                     font.bold: true
                                 }
                                 }
@@ -1117,25 +1012,25 @@ Rectangle {
                             }
                             }
 
 
                             MouseArea {
                             MouseArea {
-                                id: ballistaMouseArea
+                                id: healerMouseArea
 
 
                                 anchors.fill: parent
                                 anchors.fill: parent
                                 hoverEnabled: true
                                 hoverEnabled: true
                                 onClicked: {
                                 onClicked: {
                                     if (parent.isEnabled)
                                     if (parent.isEnabled)
-                                        productionPanel.recruitUnit("ballista");
+                                        productionPanel.recruitUnit("healer");
 
 
                                 }
                                 }
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 ToolTip.visible: containsMouse
                                 ToolTip.visible: containsMouse
-                                ToolTip.text: parent.isEnabled ? qsTr("Recruit Ballista\nCost: %1 villagers\nBuild time: %2s").arg(parent.unitInfo.cost || 180).arg((parent.unitInfo.build_time || 10).toFixed(0)) : (parent.queueTotal >= 5 ? qsTr("Queue is full (5/5)") : (unitGridContent.prod.produced_count >= unitGridContent.prod.max_units ? qsTr("Unit cap reached") : qsTr("Cannot recruit")))
+                                ToolTip.text: parent.isEnabled ? qsTr("Recruit Healer\nCost: %1 villagers\nBuild time: %2s").arg(parent.unitInfo.cost || 100).arg((parent.unitInfo.build_time || 8).toFixed(0)) : (parent.queueTotal >= 5 ? qsTr("Queue is full (5/5)") : (unitGridContent.prod.produced_count >= unitGridContent.prod.max_units ? qsTr("Unit cap reached") : qsTr("Cannot recruit")))
                                 ToolTip.delay: 300
                                 ToolTip.delay: 300
                             }
                             }
 
 
                             Rectangle {
                             Rectangle {
                                 anchors.fill: parent
                                 anchors.fill: parent
                                 color: "#ffffff"
                                 color: "#ffffff"
-                                opacity: ballistaMouseArea.pressed ? 0.2 : 0
+                                opacity: healerMouseArea.pressed ? 0.2 : 0
                                 radius: parent.radius
                                 radius: parent.radius
                             }
                             }
 
 
@@ -1165,8 +1060,8 @@ Rectangle {
                         Rectangle {
                         Rectangle {
                             property int queueTotal: (unitGridContent.prod.in_progress ? 1 : 0) + (unitGridContent.prod.queue_size || 0)
                             property int queueTotal: (unitGridContent.prod.in_progress ? 1 : 0) + (unitGridContent.prod.queue_size || 0)
                             property bool isEnabled: unitGridContent.prod.has_barracks && unitGridContent.prod.produced_count < unitGridContent.prod.max_units && queueTotal < 5
                             property bool isEnabled: unitGridContent.prod.has_barracks && unitGridContent.prod.produced_count < unitGridContent.prod.max_units && queueTotal < 5
-                            property var unitInfo: productionPanel.getUnitProductionInfo("healer")
-                            property bool isHovered: healerMouseArea.containsMouse
+                            property var unitInfo: productionPanel.getUnitProductionInfo("builder")
+                            property bool isHovered: builderMouseArea.containsMouse
 
 
                             width: 110
                             width: 110
                             height: 80
                             height: 80
@@ -1178,30 +1073,30 @@ Rectangle {
                             scale: isHovered && isEnabled ? 1.1 : 1
                             scale: isHovered && isEnabled ? 1.1 : 1
 
 
                             Image {
                             Image {
-                                id: healerRecruitIcon
+                                id: builderRecruitIcon
 
 
                                 anchors.fill: parent
                                 anchors.fill: parent
                                 fillMode: Image.PreserveAspectCrop
                                 fillMode: Image.PreserveAspectCrop
                                 smooth: true
                                 smooth: true
-                                source: productionPanel.unitIconSource("healer", unitGridContent.prod.nation_id)
+                                source: productionPanel.unitIconSource("builder", unitGridContent.prod.nation_id)
                                 visible: source !== ""
                                 visible: source !== ""
                                 opacity: parent.isEnabled ? 1 : 0.35
                                 opacity: parent.isEnabled ? 1 : 0.35
                             }
                             }
 
 
                             Text {
                             Text {
                                 anchors.centerIn: parent
                                 anchors.centerIn: parent
-                                visible: !healerRecruitIcon.visible
-                                text: productionPanel.unitIconEmoji("healer")
+                                visible: !builderRecruitIcon.visible
+                                text: productionPanel.unitIconEmoji("builder")
                                 color: parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
                                 color: parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
                                 font.pointSize: 42
                                 font.pointSize: 42
                                 opacity: parent.isEnabled ? 0.9 : 0.4
                                 opacity: parent.isEnabled ? 0.9 : 0.4
                             }
                             }
 
 
                             Rectangle {
                             Rectangle {
-                                id: healerCostBadge
+                                id: builderCostBadge
 
 
-                                width: healerCostText.implicitWidth + 12
-                                height: healerCostText.implicitHeight + 6
+                                width: builderCostText.implicitWidth + 12
+                                height: builderCostText.implicitHeight + 6
                                 anchors.horizontalCenter: parent.horizontalCenter
                                 anchors.horizontalCenter: parent.horizontalCenter
                                 anchors.bottom: parent.bottom
                                 anchors.bottom: parent.bottom
                                 anchors.bottomMargin: 6
                                 anchors.bottomMargin: 6
@@ -1211,11 +1106,11 @@ Rectangle {
                                 border.width: 1
                                 border.width: 1
 
 
                                 Text {
                                 Text {
-                                    id: healerCostText
+                                    id: builderCostText
 
 
                                     anchors.centerIn: parent
                                     anchors.centerIn: parent
-                                    text: parent.parent.unitInfo.cost || 100
-                                    color: healerCostBadge.parent.isEnabled ? "#fdf7e3" : "#8a8a8a"
+                                    text: parent.parent.unitInfo.cost || 60
+                                    color: builderCostBadge.parent.isEnabled ? "#fdf7e3" : "#8a8a8a"
                                     font.pointSize: 16
                                     font.pointSize: 16
                                     font.bold: true
                                     font.bold: true
                                 }
                                 }
@@ -1223,25 +1118,25 @@ Rectangle {
                             }
                             }
 
 
                             MouseArea {
                             MouseArea {
-                                id: healerMouseArea
+                                id: builderMouseArea
 
 
                                 anchors.fill: parent
                                 anchors.fill: parent
                                 hoverEnabled: true
                                 hoverEnabled: true
                                 onClicked: {
                                 onClicked: {
                                     if (parent.isEnabled)
                                     if (parent.isEnabled)
-                                        productionPanel.recruitUnit("healer");
+                                        productionPanel.recruitUnit("builder");
 
 
                                 }
                                 }
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 ToolTip.visible: containsMouse
                                 ToolTip.visible: containsMouse
-                                ToolTip.text: parent.isEnabled ? qsTr("Recruit Healer\nCost: %1 villagers\nBuild time: %2s").arg(parent.unitInfo.cost || 100).arg((parent.unitInfo.build_time || 8).toFixed(0)) : (parent.queueTotal >= 5 ? qsTr("Queue is full (5/5)") : (unitGridContent.prod.produced_count >= unitGridContent.prod.max_units ? qsTr("Unit cap reached") : qsTr("Cannot recruit")))
+                                ToolTip.text: parent.isEnabled ? qsTr("Recruit Builder\nCost: %1 villagers\nBuild time: %2s").arg(parent.unitInfo.cost || 60).arg((parent.unitInfo.build_time || 6).toFixed(0)) : (parent.queueTotal >= 5 ? qsTr("Queue is full (5/5)") : (unitGridContent.prod.produced_count >= unitGridContent.prod.max_units ? qsTr("Unit cap reached") : qsTr("Cannot recruit")))
                                 ToolTip.delay: 300
                                 ToolTip.delay: 300
                             }
                             }
 
 
                             Rectangle {
                             Rectangle {
                                 anchors.fill: parent
                                 anchors.fill: parent
                                 color: "#ffffff"
                                 color: "#ffffff"
-                                opacity: healerMouseArea.pressed ? 0.2 : 0
+                                opacity: builderMouseArea.pressed ? 0.2 : 0
                                 radius: parent.radius
                                 radius: parent.radius
                             }
                             }
 
 
@@ -1346,40 +1241,136 @@ Rectangle {
 
 
             }
             }
 
 
-            Rectangle {
-                property bool has_barracks: (productionPanel.selectionTick, (productionPanel.gameInstance && productionPanel.gameInstance.has_selected_type && productionPanel.gameInstance.has_selected_type("barracks")))
+            Item {
+                property bool has_barracksSelected: (productionPanel.selectionTick, (productionPanel.gameInstance && productionPanel.gameInstance.has_selected_type && productionPanel.gameInstance.has_selected_type("barracks")))
 
 
-                width: parent.width
-                height: 1
-                color: "#34495e"
-                visible: has_barracks
+                height: 20
+                visible: !has_barracksSelected
             }
             }
 
 
             Rectangle {
             Rectangle {
-                property bool has_barracks: (productionPanel.selectionTick, (productionPanel.gameInstance && productionPanel.gameInstance.has_selected_type && productionPanel.gameInstance.has_selected_type("barracks")))
+                property bool has_builder: (productionPanel.selectionTick, (productionPanel.gameInstance && productionPanel.gameInstance.has_selected_type && productionPanel.gameInstance.has_selected_type("builder")))
 
 
                 width: parent.width
                 width: parent.width
-                height: buildingsContent.height + 16
+                height: builderProductionContent.height + 16
                 color: "#1a252f"
                 color: "#1a252f"
                 radius: 6
                 radius: 6
                 border.color: "#34495e"
                 border.color: "#34495e"
                 border.width: 1
                 border.width: 1
-                visible: has_barracks
+                visible: has_builder
 
 
                 Column {
                 Column {
-                    id: buildingsContent
+                    id: builderProductionContent
+
+                    property var builderProd: (productionPanel.selectionTick, (productionPanel.gameInstance && productionPanel.gameInstance.get_selected_builder_production_state) ? productionPanel.gameInstance.get_selected_builder_production_state() : {
+                        "in_progress": false,
+                        "build_time": 10,
+                        "time_remaining": 0,
+                        "product_type": ""
+                    })
 
 
                     anchors.horizontalCenter: parent.horizontalCenter
                     anchors.horizontalCenter: parent.horizontalCenter
                     anchors.top: parent.top
                     anchors.top: parent.top
                     anchors.margins: 8
                     anchors.margins: 8
                     spacing: 8
                     spacing: 8
+                    width: parent.width - 16
+
+                    Row {
+                        anchors.horizontalCenter: parent.horizontalCenter
+                        spacing: 6
+
+                        Image {
+                            id: builderHeaderIcon
+
+                            width: 18
+                            height: 18
+                            source: productionPanel.unitIconSource("builder")
+                            fillMode: Image.PreserveAspectFit
+                            smooth: true
+                            visible: source !== ""
+                        }
+
+                        Text {
+                            anchors.verticalCenter: parent.verticalCenter
+                            text: builderHeaderIcon.visible ? qsTr("BUILDER CONSTRUCTION") : qsTr("🔨 BUILDER CONSTRUCTION")
+                            color: "#3498db"
+                            font.pointSize: 9
+                            font.bold: true
+                        }
+
+                    }
 
 
                     Text {
                     Text {
                         anchors.horizontalCenter: parent.horizontalCenter
                         anchors.horizontalCenter: parent.horizontalCenter
-                        text: qsTr("BUILD STRUCTURES")
-                        color: "#3498db"
+                        text: qsTr("Build siege weapons and structures")
+                        color: "#7f8c8d"
+                        font.pointSize: 7
+                    }
+
+                    Rectangle {
+                        width: parent.width - 20
+                        height: 20
+                        anchors.horizontalCenter: parent.horizontalCenter
+                        radius: 10
+                        color: "#0a0f14"
+                        border.color: "#2c3e50"
+                        border.width: 2
+                        visible: builderProductionContent.builderProd.in_progress
+
+                        Rectangle {
+                            anchors.left: parent.left
+                            anchors.verticalCenter: parent.verticalCenter
+                            anchors.margins: 2
+                            height: parent.height - 4
+                            width: {
+                                if (!builderProductionContent.builderProd.in_progress || builderProductionContent.builderProd.build_time <= 0)
+                                    return 0;
+
+                                var progress = 1 - (Math.max(0, builderProductionContent.builderProd.time_remaining) / builderProductionContent.builderProd.build_time);
+                                return Math.max(0, (parent.width - 4) * progress);
+                            }
+                            color: "#27ae60"
+                            radius: 8
+
+                            SequentialAnimation on opacity {
+                                running: parent.width > 0
+                                loops: Animation.Infinite
+
+                                NumberAnimation {
+                                    from: 0.8
+                                    to: 1
+                                    duration: 600
+                                }
+
+                                NumberAnimation {
+                                    from: 1
+                                    to: 0.8
+                                    duration: 600
+                                }
+
+                            }
+
+                        }
+
+                        Text {
+                            anchors.centerIn: parent
+                            text: builderProductionContent.builderProd.in_progress ? Math.max(0, builderProductionContent.builderProd.time_remaining).toFixed(1) + "s" : "Idle"
+                            color: "#ecf0f1"
+                            font.pointSize: 9
+                            font.bold: true
+                            style: Text.Outline
+                            styleColor: "#000000"
+                        }
+
+                    }
+
+                    Text {
+                        anchors.horizontalCenter: parent.horizontalCenter
+                        text: builderProductionContent.builderProd.in_progress ? qsTr("Building: %1").arg(builderProductionContent.builderProd.product_type) : qsTr("Select an item to build")
+                        color: builderProductionContent.builderProd.in_progress ? "#27ae60" : "#7f8c8d"
                         font.pointSize: 8
                         font.pointSize: 8
-                        font.bold: true
+                        font.bold: builderProductionContent.builderProd.in_progress
+                        visible: true
                     }
                     }
 
 
                     Grid {
                     Grid {
@@ -1389,8 +1380,8 @@ Rectangle {
                         rowSpacing: 8
                         rowSpacing: 8
 
 
                         Rectangle {
                         Rectangle {
-                            property bool isEnabled: true
-                            property bool isHovered: defenseTowerMouseArea.containsMouse
+                            property bool isEnabled: !builderProductionContent.builderProd.in_progress
+                            property bool isHovered: builderCatapultMouseArea.containsMouse
 
 
                             width: 110
                             width: 110
                             height: 80
                             height: 80
@@ -1401,8 +1392,201 @@ Rectangle {
                             opacity: isEnabled ? 1 : 0.5
                             opacity: isEnabled ? 1 : 0.5
                             scale: isHovered && isEnabled ? 1.1 : 1
                             scale: isHovered && isEnabled ? 1.1 : 1
 
 
+                            Image {
+                                id: builderCatapultIcon
+
+                                anchors.fill: parent
+                                anchors.margins: 6
+                                fillMode: Image.PreserveAspectCrop
+                                smooth: true
+                                source: productionPanel.unitIconSource("catapult")
+                                visible: source !== ""
+                                opacity: parent.isEnabled ? 1 : 0.35
+                            }
+
                             Text {
                             Text {
                                 anchors.centerIn: parent
                                 anchors.centerIn: parent
+                                visible: !builderCatapultIcon.visible
+                                text: productionPanel.unitIconEmoji("catapult")
+                                color: parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
+                                font.pointSize: 36
+                                opacity: parent.isEnabled ? 0.9 : 0.4
+                            }
+
+                            Text {
+                                anchors.horizontalCenter: parent.horizontalCenter
+                                anchors.bottom: parent.bottom
+                                anchors.bottomMargin: 6
+                                text: qsTr("Catapult")
+                                color: parent.isEnabled ? "#bdc3c7" : "#5a5a5a"
+                                font.pointSize: 8
+                                font.bold: true
+                            }
+
+                            MouseArea {
+                                id: builderCatapultMouseArea
+
+                                anchors.fill: parent
+                                hoverEnabled: true
+                                onClicked: {
+                                    if (parent.isEnabled)
+                                        productionPanel.builderConstruction("catapult");
+
+                                }
+                                cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
+                                ToolTip.visible: containsMouse
+                                ToolTip.text: parent.isEnabled ? qsTr("Build Catapult\nLong-range siege weapon\nEffective against structures\nBuild time: 15s") : qsTr("Already building...")
+                                ToolTip.delay: 300
+                            }
+
+                            Rectangle {
+                                anchors.fill: parent
+                                color: "#ffffff"
+                                opacity: builderCatapultMouseArea.pressed ? 0.2 : 0
+                                radius: parent.radius
+                            }
+
+                            Behavior on color {
+                                ColorAnimation {
+                                    duration: 150
+                                }
+
+                            }
+
+                            Behavior on border.color {
+                                ColorAnimation {
+                                    duration: 150
+                                }
+
+                            }
+
+                            Behavior on scale {
+                                NumberAnimation {
+                                    duration: 100
+                                }
+
+                            }
+
+                        }
+
+                        Rectangle {
+                            property bool isEnabled: !builderProductionContent.builderProd.in_progress
+                            property bool isHovered: builderBallistaMouseArea.containsMouse
+
+                            width: 110
+                            height: 80
+                            radius: 6
+                            color: isEnabled ? (isHovered ? "#1f8dd9" : "#2c3e50") : "#1a1a1a"
+                            border.color: isEnabled ? (isHovered ? "#00d4ff" : "#4a6572") : "#2a2a2a"
+                            border.width: isHovered && isEnabled ? 4 : 2
+                            opacity: isEnabled ? 1 : 0.5
+                            scale: isHovered && isEnabled ? 1.1 : 1
+
+                            Image {
+                                id: builderBallistaIcon
+
+                                anchors.fill: parent
+                                anchors.margins: 6
+                                fillMode: Image.PreserveAspectCrop
+                                smooth: true
+                                source: productionPanel.unitIconSource("ballista")
+                                visible: source !== ""
+                                opacity: parent.isEnabled ? 1 : 0.35
+                            }
+
+                            Text {
+                                anchors.centerIn: parent
+                                visible: !builderBallistaIcon.visible
+                                text: productionPanel.unitIconEmoji("ballista")
+                                color: parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
+                                font.pointSize: 36
+                                opacity: parent.isEnabled ? 0.9 : 0.4
+                            }
+
+                            Text {
+                                anchors.horizontalCenter: parent.horizontalCenter
+                                anchors.bottom: parent.bottom
+                                anchors.bottomMargin: 6
+                                text: qsTr("Ballista")
+                                color: parent.isEnabled ? "#bdc3c7" : "#5a5a5a"
+                                font.pointSize: 8
+                                font.bold: true
+                            }
+
+                            MouseArea {
+                                id: builderBallistaMouseArea
+
+                                anchors.fill: parent
+                                hoverEnabled: true
+                                onClicked: {
+                                    if (parent.isEnabled)
+                                        productionPanel.builderConstruction("ballista");
+
+                                }
+                                cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
+                                ToolTip.visible: containsMouse
+                                ToolTip.text: parent.isEnabled ? qsTr("Build Ballista\nPrecision siege weapon\nEffective against units\nBuild time: 12s") : qsTr("Already building...")
+                                ToolTip.delay: 300
+                            }
+
+                            Rectangle {
+                                anchors.fill: parent
+                                color: "#ffffff"
+                                opacity: builderBallistaMouseArea.pressed ? 0.2 : 0
+                                radius: parent.radius
+                            }
+
+                            Behavior on color {
+                                ColorAnimation {
+                                    duration: 150
+                                }
+
+                            }
+
+                            Behavior on border.color {
+                                ColorAnimation {
+                                    duration: 150
+                                }
+
+                            }
+
+                            Behavior on scale {
+                                NumberAnimation {
+                                    duration: 100
+                                }
+
+                            }
+
+                        }
+
+                        Rectangle {
+                            property bool isEnabled: !builderProductionContent.builderProd.in_progress
+                            property bool isHovered: builderDefenseTowerMouseArea.containsMouse
+
+                            width: 110
+                            height: 80
+                            radius: 6
+                            color: isEnabled ? (isHovered ? "#1f8dd9" : "#2c3e50") : "#1a1a1a"
+                            border.color: isEnabled ? (isHovered ? "#00d4ff" : "#4a6572") : "#2a2a2a"
+                            border.width: isHovered && isEnabled ? 4 : 2
+                            opacity: isEnabled ? 1 : 0.5
+                            scale: isHovered && isEnabled ? 1.1 : 1
+
+                            Image {
+                                id: builderDefenseTowerIcon
+
+                                anchors.fill: parent
+                                anchors.margins: 6
+                                fillMode: Image.PreserveAspectCrop
+                                smooth: true
+                                source: productionPanel.unitIconSource("defense_tower")
+                                visible: source !== ""
+                                opacity: parent.isEnabled ? 1 : 0.35
+                            }
+
+                            Text {
+                                anchors.centerIn: parent
+                                visible: !builderDefenseTowerIcon.visible
                                 text: "🏰"
                                 text: "🏰"
                                 color: parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
                                 color: parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
                                 font.pointSize: 36
                                 font.pointSize: 36
@@ -1414,31 +1598,31 @@ Rectangle {
                                 anchors.bottom: parent.bottom
                                 anchors.bottom: parent.bottom
                                 anchors.bottomMargin: 6
                                 anchors.bottomMargin: 6
                                 text: qsTr("Defense Tower")
                                 text: qsTr("Defense Tower")
-                                color: parent.isEnabled ? "#ecf0f1" : "#5a5a5a"
+                                color: parent.isEnabled ? "#bdc3c7" : "#5a5a5a"
                                 font.pointSize: 8
                                 font.pointSize: 8
                                 font.bold: true
                                 font.bold: true
                             }
                             }
 
 
                             MouseArea {
                             MouseArea {
-                                id: defenseTowerMouseArea
+                                id: builderDefenseTowerMouseArea
 
 
                                 anchors.fill: parent
                                 anchors.fill: parent
                                 hoverEnabled: true
                                 hoverEnabled: true
                                 onClicked: {
                                 onClicked: {
                                     if (parent.isEnabled)
                                     if (parent.isEnabled)
-                                        productionPanel.buildTower();
+                                        productionPanel.builderConstruction("defense_tower");
 
 
                                 }
                                 }
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 ToolTip.visible: containsMouse
                                 ToolTip.visible: containsMouse
-                                ToolTip.text: qsTr("Build Defense Tower\nClick map to place.\nShoots arrows at nearby enemies.")
+                                ToolTip.text: parent.isEnabled ? qsTr("Build Defense Tower\nStationary defense structure\nShoots arrows at enemies\nBuild time: 20s") : qsTr("Already building...")
                                 ToolTip.delay: 300
                                 ToolTip.delay: 300
                             }
                             }
 
 
                             Rectangle {
                             Rectangle {
                                 anchors.fill: parent
                                 anchors.fill: parent
                                 color: "#ffffff"
                                 color: "#ffffff"
-                                opacity: defenseTowerMouseArea.pressed ? 0.2 : 0
+                                opacity: builderDefenseTowerMouseArea.pressed ? 0.2 : 0
                                 radius: parent.radius
                                 radius: parent.radius
                             }
                             }
 
 
@@ -1471,17 +1655,11 @@ Rectangle {
 
 
             }
             }
 
 
-            Item {
-                property bool has_barracksSelected: (productionPanel.selectionTick, (productionPanel.gameInstance && productionPanel.gameInstance.has_selected_type && productionPanel.gameInstance.has_selected_type("barracks")))
-
-                height: 20
-                visible: !has_barracksSelected
-            }
-
             Item {
             Item {
                 property bool has_barracks: (productionPanel.selectionTick, (productionPanel.gameInstance && productionPanel.gameInstance.has_selected_type && productionPanel.gameInstance.has_selected_type("barracks")))
                 property bool has_barracks: (productionPanel.selectionTick, (productionPanel.gameInstance && productionPanel.gameInstance.has_selected_type && productionPanel.gameInstance.has_selected_type("barracks")))
+                property bool has_builder: (productionPanel.selectionTick, (productionPanel.gameInstance && productionPanel.gameInstance.has_selected_type && productionPanel.gameInstance.has_selected_type("builder")))
 
 
-                visible: !has_barracks
+                visible: !has_barracks && !has_builder
                 width: parent.width
                 width: parent.width
                 height: 200
                 height: 200
 
 

+ 25 - 1
ui/qml/StyleGuide.qml

@@ -131,8 +131,12 @@ QtObject {
         "horse_archer": "🏹🐎",
         "horse_archer": "🏹🐎",
         "horse_spearman": "🐎🛡️",
         "horse_spearman": "🐎🛡️",
         "healer": "✚",
         "healer": "✚",
+        "builder": "🔨",
         "catapult": "🛞",
         "catapult": "🛞",
         "ballista": "🎯",
         "ballista": "🎯",
+        "defense_tower": "🏰",
+        "wall": "🧱",
+        "home": "🏠",
         "default": "👤"
         "default": "👤"
     })
     })
     readonly property var unitIconSources: ({
     readonly property var unitIconSources: ({
@@ -171,6 +175,11 @@ QtObject {
             "roman_republic": root.iconPath("healer_rome.png"),
             "roman_republic": root.iconPath("healer_rome.png"),
             "carthage": root.iconPath("healer_cartaghe.png")
             "carthage": root.iconPath("healer_cartaghe.png")
         }),
         }),
+        "builder": ({
+            "default": root.iconPath("builder_rome.png"),
+            "roman_republic": root.iconPath("builder_rome.png"),
+            "carthage": root.iconPath("builder_cartaghe.png")
+        }),
         "catapult": ({
         "catapult": ({
             "default": root.iconPath("catapult_rome.png"),
             "default": root.iconPath("catapult_rome.png"),
             "roman_republic": root.iconPath("catapult_rome.png"),
             "roman_republic": root.iconPath("catapult_rome.png"),
@@ -181,13 +190,28 @@ QtObject {
             "roman_republic": root.iconPath("ballista_rome.png"),
             "roman_republic": root.iconPath("ballista_rome.png"),
             "carthage": root.iconPath("ballista_cartaghe.png")
             "carthage": root.iconPath("ballista_cartaghe.png")
         }),
         }),
+        "defense_tower": ({
+            "default": root.iconPath("defense_tower_rome.png"),
+            "roman_republic": root.iconPath("defense_tower_rome.png"),
+            "carthage": root.iconPath("defense_tower_cartaghe.png")
+        }),
+        "wall": ({
+            "default": root.iconPath("wall_rome.png"),
+            "roman_republic": root.iconPath("wall_rome.png"),
+            "carthage": root.iconPath("wall_cartaghe.png")
+        }),
+        "home": ({
+            "default": root.iconPath("house_rome.png"),
+            "roman_republic": root.iconPath("house_rome.png"),
+            "carthage": root.iconPath("house_cartaghe.png")
+        }),
         "default": ({
         "default": ({
             "default": ""
             "default": ""
         })
         })
     })
     })
 
 
     function iconPath(filename) {
     function iconPath(filename) {
-        return "qrc:/StandardOfIron/assets/visuals/icons/" + filename;
+        return Qt.resolvedUrl("../../assets/visuals/icons/" + filename);
     }
     }
 
 
 }
 }