Przeglądaj źródła

Improved healers, catapults and ballista

djeada 1 tydzień temu
rodzic
commit
a126e67c7d
100 zmienionych plików z 2742 dodań i 4375 usunięć
  1. 0 14
      CMakeLists.txt
  2. 3 3
      README.md
  3. 58 0
      TODO.md
  4. 0 15
      assets.qrc
  5. 0 182
      assets/data/nations/kingdom_of_iron.json
  6. 77 7
      assets/data/troops/base.json
  7. 5 5
      assets/maps/map_mountain.json
  8. 31 1
      assets/maps/map_rivers.json
  9. 78 2
      assets/shaders/archer_carthage.frag
  10. 14 14
      assets/shaders/archer_carthage.vert
  11. 0 183
      assets/shaders/archer_kingdom_of_iron.frag
  12. 0 31
      assets/shaders/archer_kingdom_of_iron.vert
  13. 1 9
      assets/shaders/archer_roman_republic.frag
  14. 20 18
      assets/shaders/archer_roman_republic.vert
  15. 133 0
      assets/shaders/catapult.frag
  16. 35 0
      assets/shaders/catapult.vert
  17. 294 55
      assets/shaders/healer_carthage.frag
  18. 64 10
      assets/shaders/healer_carthage.vert
  19. 0 103
      assets/shaders/healer_kingdom_of_iron.frag
  20. 0 29
      assets/shaders/healer_kingdom_of_iron.vert
  21. 280 101
      assets/shaders/healer_roman_republic.frag
  22. 2 8
      assets/shaders/healer_roman_republic.vert
  23. 46 3
      assets/shaders/horse_archer_carthage.frag
  24. 2 10
      assets/shaders/horse_archer_carthage.vert
  25. 0 354
      assets/shaders/horse_archer_kingdom_of_iron.frag
  26. 0 32
      assets/shaders/horse_archer_kingdom_of_iron.vert
  27. 2 8
      assets/shaders/horse_archer_roman_republic.vert
  28. 51 3
      assets/shaders/horse_spearman_carthage.frag
  29. 2 10
      assets/shaders/horse_spearman_carthage.vert
  30. 0 354
      assets/shaders/horse_spearman_kingdom_of_iron.frag
  31. 0 32
      assets/shaders/horse_spearman_kingdom_of_iron.vert
  32. 2 8
      assets/shaders/horse_spearman_roman_republic.vert
  33. 58 4
      assets/shaders/horse_swordsman_carthage.frag
  34. 2 10
      assets/shaders/horse_swordsman_carthage.vert
  35. 0 354
      assets/shaders/horse_swordsman_kingdom_of_iron.frag
  36. 0 32
      assets/shaders/horse_swordsman_kingdom_of_iron.vert
  37. 2 8
      assets/shaders/horse_swordsman_roman_republic.vert
  38. 39 20
      assets/shaders/spearman_carthage.frag
  39. 39 26
      assets/shaders/spearman_carthage.vert
  40. 0 328
      assets/shaders/spearman_kingdom_of_iron.frag
  41. 0 31
      assets/shaders/spearman_kingdom_of_iron.vert
  42. 1 9
      assets/shaders/spearman_roman_republic.frag
  43. 22 18
      assets/shaders/spearman_roman_republic.vert
  44. 3 2
      assets/shaders/swordsman_carthage.frag
  45. 13 13
      assets/shaders/swordsman_carthage.vert
  46. 0 180
      assets/shaders/swordsman_kingdom_of_iron.frag
  47. 0 31
      assets/shaders/swordsman_kingdom_of_iron.vert
  48. 7 2
      assets/shaders/swordsman_roman_republic.frag
  49. 22 18
      assets/shaders/swordsman_roman_republic.vert
  50. 2 0
      game/CMakeLists.txt
  51. 1 1
      game/core/component.h
  52. 1 1
      game/core/serialization.cpp
  53. 2 8
      game/systems/nation_id.h
  54. 9 12
      game/systems/nation_registry.cpp
  55. 1 1
      game/systems/nation_registry.h
  56. 1 1
      game/systems/production_service.h
  57. 103 0
      game/units/ballista.cpp
  58. 22 0
      game/units/ballista.h
  59. 107 0
      game/units/catapult.cpp
  60. 17 0
      game/units/catapult.h
  61. 12 0
      game/units/factory.cpp
  62. 28 0
      game/units/spawn_type.h
  63. 7 7
      game/units/troop_catalog.cpp
  64. 15 1
      game/units/troop_type.h
  65. 1 1
      game/units/unit.h
  66. 6 17
      render/CMakeLists.txt
  67. 46 0
      render/entity/ballista_renderer.cpp
  68. 9 0
      render/entity/ballista_renderer.h
  69. 1 4
      render/entity/barracks_renderer.cpp
  70. 46 0
      render/entity/catapult_renderer.cpp
  71. 9 0
      render/entity/catapult_renderer.h
  72. 2 2
      render/entity/horse_spearman_renderer_base.cpp
  73. 294 0
      render/entity/nations/carthage/ballista_renderer.cpp
  74. 9 0
      render/entity/nations/carthage/ballista_renderer.h
  75. 254 0
      render/entity/nations/carthage/catapult_renderer.cpp
  76. 9 0
      render/entity/nations/carthage/catapult_renderer.h
  77. 235 1
      render/entity/nations/carthage/healer_renderer.cpp
  78. 20 9
      render/entity/nations/carthage/healer_style.cpp
  79. 4 2
      render/entity/nations/carthage/healer_style.h
  80. 1 1
      render/entity/nations/carthage/horse_spearman_renderer.cpp
  81. 51 2
      render/entity/nations/carthage/horse_swordsman_renderer.cpp
  82. 1 6
      render/entity/nations/carthage/spearman_renderer.cpp
  83. 8 1
      render/entity/nations/carthage/swordsman_renderer.cpp
  84. 0 321
      render/entity/nations/kingdom/archer_renderer.cpp
  85. 0 15
      render/entity/nations/kingdom/archer_renderer.h
  86. 0 35
      render/entity/nations/kingdom/archer_style.cpp
  87. 0 30
      render/entity/nations/kingdom/archer_style.h
  88. 0 694
      render/entity/nations/kingdom/barracks_renderer.cpp
  89. 0 9
      render/entity/nations/kingdom/barracks_renderer.h
  90. 0 215
      render/entity/nations/kingdom/healer_renderer.cpp
  91. 0 15
      render/entity/nations/kingdom/healer_renderer.h
  92. 0 31
      render/entity/nations/kingdom/healer_style.cpp
  93. 0 27
      render/entity/nations/kingdom/healer_style.h
  94. 0 58
      render/entity/nations/kingdom/horse_archer_renderer.cpp
  95. 0 9
      render/entity/nations/kingdom/horse_archer_renderer.h
  96. 0 56
      render/entity/nations/kingdom/horse_spearman_renderer.cpp
  97. 0 9
      render/entity/nations/kingdom/horse_spearman_renderer.h
  98. 0 58
      render/entity/nations/kingdom/horse_swordsman_renderer.cpp
  99. 0 9
      render/entity/nations/kingdom/horse_swordsman_renderer.h
  100. 0 16
      render/entity/nations/kingdom/horse_swordsman_style.cpp

+ 0 - 14
CMakeLists.txt

@@ -181,8 +181,6 @@ if(QT_VERSION_MAJOR EQUAL 6)
         RESOURCES
             assets/shaders/archer.frag
             assets/shaders/archer.vert
-            assets/shaders/archer_kingdom_of_iron.frag
-            assets/shaders/archer_kingdom_of_iron.vert
             assets/shaders/archer_roman_republic.frag
             assets/shaders/archer_roman_republic.vert
             assets/shaders/archer_carthage.frag
@@ -204,36 +202,26 @@ if(QT_VERSION_MAJOR EQUAL 6)
             assets/shaders/ground_plane.vert
             assets/shaders/swordsman.frag
             assets/shaders/swordsman.vert
-            assets/shaders/swordsman_kingdom_of_iron.frag
-            assets/shaders/swordsman_kingdom_of_iron.vert
             assets/shaders/swordsman_roman_republic.frag
             assets/shaders/swordsman_roman_republic.vert
             assets/shaders/swordsman_carthage.frag
             assets/shaders/swordsman_carthage.vert
             assets/shaders/horse_swordsman.frag
             assets/shaders/horse_swordsman.vert
-            assets/shaders/horse_swordsman_kingdom_of_iron.frag
-            assets/shaders/horse_swordsman_kingdom_of_iron.vert
             assets/shaders/horse_swordsman_roman_republic.frag
             assets/shaders/horse_swordsman_roman_republic.vert
             assets/shaders/horse_swordsman_carthage.frag
             assets/shaders/horse_swordsman_carthage.vert
-            assets/shaders/horse_archer_kingdom_of_iron.frag
-            assets/shaders/horse_archer_kingdom_of_iron.vert
             assets/shaders/horse_archer_roman_republic.frag
             assets/shaders/horse_archer_roman_republic.vert
             assets/shaders/horse_archer_carthage.frag
             assets/shaders/horse_archer_carthage.vert
-            assets/shaders/horse_spearman_kingdom_of_iron.frag
-            assets/shaders/horse_spearman_kingdom_of_iron.vert
             assets/shaders/horse_spearman_roman_republic.frag
             assets/shaders/horse_spearman_roman_republic.vert
             assets/shaders/horse_spearman_carthage.frag
             assets/shaders/horse_spearman_carthage.vert
             assets/shaders/healer.frag
             assets/shaders/healer.vert
-            assets/shaders/healer_kingdom_of_iron.frag
-            assets/shaders/healer_kingdom_of_iron.vert
             assets/shaders/healer_roman_republic.frag
             assets/shaders/healer_roman_republic.vert
             assets/shaders/healer_carthage.frag
@@ -248,8 +236,6 @@ if(QT_VERSION_MAJOR EQUAL 6)
             assets/shaders/riverbank.vert
             assets/shaders/spearman.frag
             assets/shaders/spearman.vert
-            assets/shaders/spearman_kingdom_of_iron.frag
-            assets/shaders/spearman_kingdom_of_iron.vert
             assets/shaders/spearman_roman_republic.frag
             assets/shaders/spearman_roman_republic.vert
             assets/shaders/spearman_carthage.frag

+ 3 - 3
README.md

@@ -6,7 +6,7 @@ A modern real-time strategy (RTS) game built with C++20, Qt 6, and OpenGL 3.3 Co
 
 ### Core Gameplay
 - **Unit Production**: Build archers from barracks with production queues
-- **Distinct Nations**: Choose between the Kingdom of Iron, Roman Republic, and Carthaginian Empire
+- **Distinct Nations**: Choose between the Roman Republic and Carthaginian Empire
 - **Rally Points**: Set spawn locations for newly produced units (visual yellow flags)
 - **Combat System**: Ranged archer combat with health bars and visual arrow projectiles
 - **Barrack Capture**: Take control of neutral or enemy barracks with 3× troop advantage
@@ -420,13 +420,13 @@ Quick start for contributors:
 
 ## Nation System Migration Plan
 
-This roadmap replaces the single “Kingdom of Iron” template with a scalable civilization layer that allows Romans, Carthage, and future nations to share troop classes but diverge on stats, formations, and visuals.
+This roadmap replaces the single nation template with a scalable civilization layer that allows Romans, Carthage, and future nations to share troop classes but diverge on stats, formations, and visuals.
 
 ### Phase 1 — Core Data Foundations
 - Introduce a `TroopClass` catalog describing baseline stats/metadata for each `Game::Units::TroopType` (health, speed, damage, default renderer, individuals per unit, etc.).
 - Refactor existing unit constructors (`game/units/*.cpp`) to hydrate components from the catalog instead of hard-coded literals; keep overrides minimal to validate the abstraction.
 - Extend `Nation` (`game/systems/nation_registry.h`) with a `NationTroopVariant` map that captures per-nation overrides (stat deltas, formation preference, renderer id).
-- Persist current “Kingdom of Iron” values into `assets/data/troops/base.json` plus `assets/data/nations/kingdom_of_iron.json` so runtime data mirrors today’s behavior.
+- Persist current values into `assets/data/troops/base.json` plus nation JSONs so runtime data mirrors today’s behavior.
 
 ### Phase 2 — Loading & Profiles
 - Add a JSON loader (`game/systems/nation_loader.*`) that builds `Nation` objects from disk and registers them through `NationRegistry::initializeDefaults`.

+ 58 - 0
TODO.md

@@ -0,0 +1,58 @@
+# Campaign Expansion Plan (Hannibal vs Rome)
+
+Goal: define the campaign authoring pipeline and player-facing experience before implementing code. Two complementary tracks: (1) developer tooling and data needed to author missions/maps/events reliably, and (2) runtime systems + UI that surface those missions, track progress in SQLite, and present a cohesive Hannibal-vs-Rome campaign.
+
+## A) Mission Framework (Authoring)
+- Mission config schema (JSON) — what authors control:
+  - Identity: `id`, `title`, `summary`, `order_index`, `world_region_id` (ties to strategy map).
+  - Level: `map_path` (playable map), optional `base_map` (override a shared base), biome override (ground type, lighting, weather seeds).
+  - Player setup: nation, color, starting units/buildings, starting resources/tech, population cap tweaks, rally points.
+  - AI setups: nation, color, difficulty, personality knobs (aggression/defense/harass), build/tech pacing, waves/spawns (timing, composition, entry points), reinforcements on triggers.
+  - Victory conditions: destroy target(s), survive duration, hold capture points, escort to zone, resource quota; allow multiple win clauses and optional secondary objectives.
+  - Defeat conditions: lose key structure/unit, timer expires, lose control points.
+  - Events: timed or state-based triggers that spawn units, play VO/text, change AI stance, weather shifts.
+  - Presentation: thumbnail, intro text, loading-tip, ambient track; optional cut-in messages.
+  - Rewards/progression: unlock next mission, medal tier, stat tracking fields.
+- Validator CLI (dev safety rail): JSON schema lint; cross-check referenced assets/maps exist; verify `order_index` is contiguous; ensure entity/unit types referenced exist; optional “dry-run compile” to catch missing fields. Hook into CMake/`make` so invalid missions break the build.
+- Loader wiring: `start_campaign_mission(id)` reads mission config, constructs player/AI configs, applies biome/ground overrides, configures victory service from mission rules, and injects events/waves into existing systems. Removes any hardcoded Carthage-vs-Rome defaults.
+
+## B) Campaign Content (Hannibal Perspective, 8 Missions)
+- Mission 1 (Crossing the Rhône): teach crossings/tempo; map with two fords + bridge; objective to hold crossings for N minutes or destroy Roman outpost; Carthage starts with a veteran core, minimal eco; timed reinforcements (rafts) to introduce pacing; Romans probe then counter with cavalry if timer hits.
+- Later beats (to detail later): Ticino (flanking tutorial), Trebia (ambush with cold river penalty), Trasimene (fog/LOS ambush), Cannae (double envelopment), Campania (siege/attrition), Alps (retreat/supply/logistics strain), Zama (finale vs Scipio, elephant counters).
+- Progression rules: seed 8 missions with only Mission 1 unlocked; victory sets completed/unlocks next; defeat increments attempts only; stats tracked for UI medals.
+
+### Mission-to-Ground-Type Mapping
+| **Mission**                          | **Assigned Ground Type**       |
+| ------------------------------------ | ------------------------------ |
+| **Crossing the Rhône**               | **Light-Brown Rocky Soil**     |
+| **Ticino (flanking tutorial)**       | **Dark Fertile Farmland Soil** |
+| **Trebia (ambush)**                  | **Deep Green + Mud**           |
+| **Trasimene (fog ambush)**           | **Dry Mediterranean Grass**    |
+| **Cannae (double envelopment)**      | **Dry Mediterranean Grass**    |
+| **Campania (siege/attrition)**       | **Light-Brown Rocky Soil**     |
+| **Alps (retreat / supply struggle)** | **Alpine Rock + Snow Mix**     |
+| **Zama (finale)**                    | **Dry Mediterranean Grass**    |
+
+## C) Player Progress (SQLite)
+- Tables:
+  - `campaigns`: id/slug, title, summary, map_path, mission_config_path, order_index, difficulty tag, thumbnail, world_region_id.
+  - `campaign_progress`: campaign_id PK, completed (bool), unlocked (bool), best_time (ms or ISO duration), attempts (int), completed_at (timestamp).
+- Migration: bump schema version; create/alter tables to add new columns; seed 8 missions (only Mission 1 unlocked); keep backward compatibility with existing saves.
+- Runtime flow: on start, load config; on victory, mark completed, unlock next mission, update best_time/attempts; on defeat, increment attempts only; refresh `available_campaigns` for UI bindings.
+
+## D) UI/UX for Campaign Screen
+- Layout: left column list of missions with title, short blurb, lock/completed badges, difficulty tag; right pane is the interactive Mediterranean map.
+- Detail card (when selected): objectives (primary/secondary), recommended approach, start CTA, stats (attempts, best time), thumbnail, mission difficulty. Disable start when locked; show why it’s locked.
+- Progress affordances: header progress bar (completed/total) and “Continue Campaign” button that picks the first unlocked-but-incomplete mission.
+- Interaction: selecting a mission highlights its province on the map, pans/zooms to it, and shows a tooltip with historical note/control status.
+
+## E) Mediterranean Strategic Map (Right Pane)
+- Asset/data: stylized Mediterranean map (Iberia → Asia Minor, North Africa → Alps) authored as static geometry plus `assets/campaign_map/provinces.json` describing province polygons, owner, label anchors, and mission linkages.
+- Rendering/interaction: pan/zoom with bounds; hover highlights province; click shows tooltip (who controls it, mission association, short historical note). Keep draw calls reasonable (cached geometry, batched fills).
+- State visualization: provinces tinted by control (Carthage/Rome/neutral); selected mission’s province pulses or outlines; optional subtle sea/shore gradient for depth.
+- Data link: mission config includes `world_region_id`; map uses that to focus/tint and to show progress along Hannibal’s path.
+
+## F) Next Decisions to Unblock Implementation
+- Freeze mission config fields and file locations (e.g., `assets/campaigns/mission_crossing_rhone.json`) so the validator and loader targets are stable.
+- Approve province list and visual style for the Mediterranean map (level of stylization, color palette, label density).
+- Confirm Mission 1 objectives, map layout (two fords + bridge), and which existing systems (victory service, AI waves/events) to extend or reuse for timed reinforcements and stance changes.

+ 0 - 15
assets.qrc

@@ -3,8 +3,6 @@
         <!-- Shader files -->
         <file>assets/shaders/archer.frag</file>
         <file>assets/shaders/archer.vert</file>
-        <file>assets/shaders/archer_kingdom_of_iron.frag</file>
-        <file>assets/shaders/archer_kingdom_of_iron.vert</file>
         <file>assets/shaders/archer_roman_republic.frag</file>
         <file>assets/shaders/archer_roman_republic.vert</file>
         <file>assets/shaders/archer_carthage.frag</file>
@@ -26,36 +24,26 @@
         <file>assets/shaders/ground_plane.vert</file>
         <file>assets/shaders/swordsman.frag</file>
         <file>assets/shaders/swordsman.vert</file>
-        <file>assets/shaders/swordsman_kingdom_of_iron.frag</file>
-        <file>assets/shaders/swordsman_kingdom_of_iron.vert</file>
         <file>assets/shaders/swordsman_roman_republic.frag</file>
         <file>assets/shaders/swordsman_roman_republic.vert</file>
         <file>assets/shaders/swordsman_carthage.frag</file>
         <file>assets/shaders/swordsman_carthage.vert</file>
         <file>assets/shaders/horse_swordsman.frag</file>
         <file>assets/shaders/horse_swordsman.vert</file>
-        <file>assets/shaders/horse_swordsman_kingdom_of_iron.frag</file>
-        <file>assets/shaders/horse_swordsman_kingdom_of_iron.vert</file>
         <file>assets/shaders/horse_swordsman_roman_republic.frag</file>
         <file>assets/shaders/horse_swordsman_roman_republic.vert</file>
         <file>assets/shaders/horse_swordsman_carthage.frag</file>
         <file>assets/shaders/horse_swordsman_carthage.vert</file>
-        <file>assets/shaders/horse_archer_kingdom_of_iron.frag</file>
-        <file>assets/shaders/horse_archer_kingdom_of_iron.vert</file>
         <file>assets/shaders/horse_archer_roman_republic.frag</file>
         <file>assets/shaders/horse_archer_roman_republic.vert</file>
         <file>assets/shaders/horse_archer_carthage.frag</file>
         <file>assets/shaders/horse_archer_carthage.vert</file>
-        <file>assets/shaders/horse_spearman_kingdom_of_iron.frag</file>
-        <file>assets/shaders/horse_spearman_kingdom_of_iron.vert</file>
         <file>assets/shaders/horse_spearman_roman_republic.frag</file>
         <file>assets/shaders/horse_spearman_roman_republic.vert</file>
         <file>assets/shaders/horse_spearman_carthage.frag</file>
         <file>assets/shaders/horse_spearman_carthage.vert</file>
         <file>assets/shaders/healer.frag</file>
         <file>assets/shaders/healer.vert</file>
-        <file>assets/shaders/healer_kingdom_of_iron.frag</file>
-        <file>assets/shaders/healer_kingdom_of_iron.vert</file>
         <file>assets/shaders/healer_roman_republic.frag</file>
         <file>assets/shaders/healer_roman_republic.vert</file>
         <file>assets/shaders/healer_carthage.frag</file>
@@ -70,8 +58,6 @@
         <file>assets/shaders/riverbank.vert</file>
         <file>assets/shaders/spearman.frag</file>
         <file>assets/shaders/spearman.vert</file>
-        <file>assets/shaders/spearman_kingdom_of_iron.frag</file>
-        <file>assets/shaders/spearman_kingdom_of_iron.vert</file>
         <file>assets/shaders/spearman_roman_republic.frag</file>
         <file>assets/shaders/spearman_roman_republic.vert</file>
         <file>assets/shaders/spearman_carthage.frag</file>
@@ -114,7 +100,6 @@
 
         <!-- Gameplay data -->
         <file>assets/data/troops/base.json</file>
-        <file>assets/data/nations/kingdom_of_iron.json</file>
         <file>assets/data/nations/roman_republic.json</file>
         <file>assets/data/nations/carthage.json</file>
     </qresource>

+ 0 - 182
assets/data/nations/kingdom_of_iron.json

@@ -1,182 +0,0 @@
-{
-  "id": "kingdom_of_iron",
-  "display_name": "Kingdom of Iron",
-  "primary_building": "barracks",
-  "formation_type": "roman",
-  "troops": [
-    {
-      "id": "archer",
-      "display_name": "Archer",
-      "production": {
-        "cost": 50,
-        "build_time": 5.0,
-        "priority": 10,
-        "is_melee": false
-      },
-      "combat": {
-        "health": 80,
-        "max_health": 80,
-        "speed": 3.0,
-        "vision_range": 16.0,
-        "ranged_range": 6.0,
-        "ranged_damage": 12,
-        "ranged_cooldown": 1.2,
-        "melee_range": 1.5,
-        "melee_damage": 5,
-        "melee_cooldown": 0.8,
-        "can_ranged": true,
-        "can_melee": true
-      },
-      "visuals": {
-        "render_scale": 0.5,
-        "selection_ring_size": 1.2,
-        "selection_ring_y_offset": 0.0,
-        "selection_ring_ground_offset": 0.0,
-        "renderer_id": "troops/kingdom/archer"
-      },
-      "formation": {
-        "individuals_per_unit": 20,
-        "max_units_per_row": 5
-      }
-    },
-    {
-      "id": "swordsman",
-      "display_name": "Swordsman",
-      "production": {
-        "cost": 100,
-        "build_time": 8.0,
-        "priority": 10,
-        "is_melee": true
-      },
-      "combat": {
-        "health": 150,
-        "max_health": 150,
-        "speed": 2.0,
-        "vision_range": 14.0,
-        "ranged_range": 1.5,
-        "ranged_damage": 5,
-        "ranged_cooldown": 2.0,
-        "melee_range": 1.5,
-        "melee_damage": 20,
-        "melee_cooldown": 0.6,
-        "can_ranged": false,
-        "can_melee": true
-      },
-      "visuals": {
-        "render_scale": 0.6,
-        "selection_ring_size": 1.1,
-        "selection_ring_y_offset": 0.0,
-        "selection_ring_ground_offset": 0.0,
-        "renderer_id": "troops/kingdom/swordsman"
-      },
-      "formation": {
-        "individuals_per_unit": 15,
-        "max_units_per_row": 5
-      }
-    },
-    {
-      "id": "spearman",
-      "display_name": "Spearman",
-      "production": {
-        "cost": 75,
-        "build_time": 6.0,
-        "priority": 5,
-        "is_melee": true
-      },
-      "combat": {
-        "health": 120,
-        "max_health": 120,
-        "speed": 2.5,
-        "vision_range": 15.0,
-        "ranged_range": 2.5,
-        "ranged_damage": 8,
-        "ranged_cooldown": 1.5,
-        "melee_range": 2.5,
-        "melee_damage": 18,
-        "melee_cooldown": 0.8,
-        "can_ranged": false,
-        "can_melee": true
-      },
-      "visuals": {
-        "render_scale": 0.55,
-        "selection_ring_size": 1.4,
-        "selection_ring_y_offset": 0.0,
-        "selection_ring_ground_offset": 0.0,
-        "renderer_id": "troops/kingdom/spearman"
-      },
-      "formation": {
-        "individuals_per_unit": 24,
-        "max_units_per_row": 6
-      }
-    },
-    {
-      "id": "horse_swordsman",
-      "display_name": "Mounted Knight",
-      "production": {
-        "cost": 150,
-        "build_time": 10.0,
-        "priority": 15,
-        "is_melee": true
-      },
-      "combat": {
-        "health": 200,
-        "max_health": 200,
-        "speed": 3.0,
-        "vision_range": 16.0,
-        "ranged_range": 1.5,
-        "ranged_damage": 5,
-        "ranged_cooldown": 2.0,
-        "melee_range": 2.0,
-        "melee_damage": 25,
-        "melee_cooldown": 0.8,
-        "can_ranged": false,
-        "can_melee": true
-      },
-      "visuals": {
-        "render_scale": 0.8,
-        "selection_ring_size": 2.0,
-        "selection_ring_y_offset": 0.0,
-        "renderer_id": "troops/kingdom/horse_swordsman"
-      },
-      "formation": {
-        "individuals_per_unit": 9,
-        "max_units_per_row": 3
-      }
-    },
-    {
-      "id": "healer",
-      "display_name": "Healer",
-      "production": {
-        "cost": 75,
-        "build_time": 7.0,
-        "priority": 8,
-        "is_melee": false
-      },
-      "combat": {
-        "health": 100,
-        "max_health": 100,
-        "speed": 2.5,
-        "vision_range": 14.0,
-        "ranged_range": 8.0,
-        "ranged_damage": 5,
-        "ranged_cooldown": 2.0,
-        "melee_range": 1.5,
-        "melee_damage": 3,
-        "melee_cooldown": 1.5,
-        "can_ranged": false,
-        "can_melee": true
-      },
-      "visuals": {
-        "render_scale": 0.55,
-        "selection_ring_size": 1.2,
-        "selection_ring_y_offset": 0.0,
-        "selection_ring_ground_offset": 0.0,
-        "renderer_id": "troops/kingdom/healer"
-      },
-      "formation": {
-        "individuals_per_unit": 1,
-        "max_units_per_row": 1
-      }
-    }
-  ]
-}

+ 77 - 7
assets/data/troops/base.json

@@ -28,7 +28,7 @@
         "selection_ring_size": 1.2,
         "selection_ring_y_offset": 0.0,
         "selection_ring_ground_offset": 0.0,
-        "renderer_id": "troops/kingdom/archer"
+        "renderer_id": "troops/roman/archer"
       },
       "formation": {
         "individuals_per_unit": 20,
@@ -63,7 +63,7 @@
         "selection_ring_size": 1.1,
         "selection_ring_y_offset": 0.0,
         "selection_ring_ground_offset": 0.0,
-        "renderer_id": "troops/kingdom/swordsman"
+        "renderer_id": "troops/roman/swordsman"
       },
       "formation": {
         "individuals_per_unit": 15,
@@ -98,7 +98,7 @@
         "selection_ring_size": 1.4,
         "selection_ring_y_offset": 0.0,
         "selection_ring_ground_offset": 0.0,
-        "renderer_id": "troops/kingdom/spearman"
+        "renderer_id": "troops/roman/spearman"
       },
       "formation": {
         "individuals_per_unit": 24,
@@ -132,7 +132,7 @@
         "render_scale": 0.8,
         "selection_ring_size": 2.0,
         "selection_ring_y_offset": 0.0,
-        "renderer_id": "troops/kingdom/horse_swordsman"
+        "renderer_id": "troops/roman/horse_swordsman"
       },
       "formation": {
         "individuals_per_unit": 9,
@@ -166,7 +166,7 @@
         "render_scale": 0.75,
         "selection_ring_size": 1.8,
         "selection_ring_y_offset": 0.0,
-        "renderer_id": "troops/kingdom/horse_archer"
+        "renderer_id": "troops/roman/horse_archer"
       },
       "formation": {
         "individuals_per_unit": 10,
@@ -200,7 +200,7 @@
         "render_scale": 0.78,
         "selection_ring_size": 1.9,
         "selection_ring_y_offset": 0.0,
-        "renderer_id": "troops/kingdom/horse_spearman"
+        "renderer_id": "troops/roman/horse_spearman"
       },
       "formation": {
         "individuals_per_unit": 9,
@@ -235,7 +235,77 @@
         "selection_ring_size": 1.2,
         "selection_ring_y_offset": 0.0,
         "selection_ring_ground_offset": 0.0,
-        "renderer_id": "troops/kingdom/healer"
+        "renderer_id": "troops/roman/healer"
+      },
+      "formation": {
+        "individuals_per_unit": 1,
+        "max_units_per_row": 1
+      }
+    },
+    {
+      "id": "catapult",
+      "display_name": "Catapult",
+      "production": {
+        "cost": 250,
+        "build_time": 15.0,
+        "priority": 5,
+        "is_melee": false
+      },
+      "combat": {
+        "health": 300,
+        "max_health": 300,
+        "speed": 1.0,
+        "vision_range": 20.0,
+        "ranged_range": 15.0,
+        "ranged_damage": 80,
+        "ranged_cooldown": 5.0,
+        "melee_range": 1.5,
+        "melee_damage": 5,
+        "melee_cooldown": 2.0,
+        "can_ranged": true,
+        "can_melee": false
+      },
+      "visuals": {
+        "render_scale": 1.2,
+        "selection_ring_size": 1.25,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/roman/catapult"
+      },
+      "formation": {
+        "individuals_per_unit": 1,
+        "max_units_per_row": 1
+      }
+    },
+    {
+      "id": "ballista",
+      "display_name": "Ballista",
+      "production": {
+        "cost": 200,
+        "build_time": 12.0,
+        "priority": 6,
+        "is_melee": false
+      },
+      "combat": {
+        "health": 200,
+        "max_health": 200,
+        "speed": 1.5,
+        "vision_range": 22.0,
+        "ranged_range": 18.0,
+        "ranged_damage": 45,
+        "ranged_cooldown": 3.0,
+        "melee_range": 1.5,
+        "melee_damage": 5,
+        "melee_cooldown": 2.0,
+        "can_ranged": true,
+        "can_melee": false
+      },
+      "visuals": {
+        "render_scale": 1.0,
+        "selection_ring_size": 1.0,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/roman/ballista"
       },
       "formation": {
         "individuals_per_unit": 1,

+ 5 - 5
assets/maps/map_mountain.json

@@ -82,7 +82,7 @@
       "z": 75,
       "playerId": 1,
       "maxPopulation": 200,
-      "nation": "kingdom_of_iron"
+      "nation": "roman_republic"
     },
     {
       "type": "archer",
@@ -132,7 +132,7 @@
       "z": 225,
       "playerId": 2,
       "maxPopulation": 200,
-      "nation": "kingdom_of_iron"
+      "nation": "roman_republic"
     },
     {
       "type": "archer",
@@ -175,7 +175,7 @@
       "x": 150,
       "z": 150,
       "maxPopulation": 180,
-      "nation": "kingdom_of_iron"
+      "nation": "roman_republic"
     },
     {
       "type": "barracks",
@@ -183,7 +183,7 @@
       "z": 225,
       "playerId": 3,
       "maxPopulation": 150,
-      "nation": "kingdom_of_iron"
+      "nation": "roman_republic"
     },
     {
       "type": "swordsman",
@@ -214,7 +214,7 @@
       "x": 225,
       "z": 75,
       "maxPopulation": 150,
-      "nation": "kingdom_of_iron"
+      "nation": "roman_republic"
     }
   ],
   "firecamps": [

+ 31 - 1
assets/maps/map_rivers.json

@@ -91,7 +91,7 @@
       "playerId": 1
     },
     {
-      "type": "archer",
+      "type": "swordsman",
       "x": 32,
       "z": 28,
       "playerId": 1
@@ -120,6 +120,24 @@
       "z": 30,
       "playerId": 1
     },
+    {
+      "type": "healer",
+      "x": 33,
+      "z": 27,
+      "playerId": 1
+    },
+    {
+      "type": "ballista",
+      "x": 27,
+      "z": 34,
+      "playerId": 1
+    },
+    {
+      "type": "catapult",
+      "x": 27,
+      "z": 34,
+      "playerId": 1
+    },
     {
       "type": "barracks",
       "x": 90,
@@ -152,6 +170,18 @@
       "z": 90,
       "playerId": 2
     },
+    {
+      "type": "healer",
+      "x": 87,
+      "z": 93,
+      "playerId": 2
+    },
+    {
+      "type": "catapult",
+      "x": 93,
+      "z": 86,
+      "playerId": 2
+    },
     {
       "type": "barracks",
       "x": 60,

+ 78 - 2
assets/shaders/archer_carthage.frag

@@ -594,6 +594,81 @@ void main() {
                                 curvature);
   } else if (looks_beard && is_face_region) {
     material = make_hair_sample(CANON_BEARD, Nw, Tw, Bw, v_worldPos, wet_mask);
+  } else if (is_armor) {
+    // Reuse the spearman armor stack (leather + scales + mail) for consistent
+    // Carthage torso armor.
+    vec3 leather_base = vec3(0.44, 0.30, 0.19);
+    vec3 linen_base = vec3(0.86, 0.80, 0.72);
+    vec3 bronze_base = vec3(0.62, 0.46, 0.20);
+    vec3 chain_base = vec3(0.78, 0.80, 0.82);
+
+    MaterialSample leather = make_leather_sample(
+        leather_base, Nw, Tw, Bw, v_worldPos, clamp(v_leatherTension, 0.0, 1.0),
+        clamp(v_bodyHeight, 0.0, 1.0), v_armorLayer, wet_mask, curvature);
+    MaterialSample linen =
+        make_linen_sample(linen_base, Nw, Tw, Bw, v_worldPos,
+                          clamp(v_bodyHeight, 0.0, 1.0), wet_mask, curvature);
+    MaterialSample scales = make_bronze_sample(bronze_base, Nw, Tw, Bw,
+                                               v_worldPos, wet_mask, curvature);
+    MaterialSample mail = make_metal_sample(chain_base, Nw, Tw, Bw, v_worldPos,
+                                            wet_mask, curvature);
+
+    float torsoBand = 1.0;
+    float skirtBand = 0.0;
+    float mailBlend = clamp(smoothstep(0.25, 0.85,
+                                       fbm(v_worldPos * 1.2, Nw, 2.5) +
+                                           v_leatherTension * 0.2),
+                            0.0, 1.0) *
+                      torsoBand * 0.30;
+    float scaleBlend = torsoBand * 0.55;
+    float linenBlend = skirtBand * 0.40;
+    float leatherOverlay = skirtBand * 0.90 + torsoBand * 0.30;
+
+    float edge = 1.0 - clamp(dot(Nw, vec3(0.0, 1.0, 0.0)), 0.0, 1.0);
+    vec3 highlight = vec3(0.10, 0.08, 0.05) * smoothstep(0.3, 0.9, edge);
+
+    vec3 albedo = leather.albedo;
+    albedo = mix(albedo, linen.albedo, linenBlend);
+    albedo = mix(albedo, scales.albedo, scaleBlend);
+    albedo = mix(albedo, mail.albedo, mailBlend);
+    albedo = mix(albedo, leather.albedo + highlight, leatherOverlay);
+
+    float leather_depth = clamp(
+        leatherOverlay * 0.8 + linenBlend * 0.2 + scaleBlend * 0.15, 0.0, 1.0);
+    albedo = mix(albedo, albedo * 0.88 + vec3(0.04, 0.03, 0.02),
+                 leather_depth * 0.35);
+
+    vec3 normal = leather.normal;
+    normal = normalize(mix(normal, linen.normal, linenBlend));
+    normal = normalize(mix(normal, scales.normal, scaleBlend));
+    normal = normalize(mix(normal, mail.normal, mailBlend));
+
+    float roughness = leather.roughness;
+    roughness = mix(roughness, linen.roughness, linenBlend);
+    roughness = mix(roughness, scales.roughness, scaleBlend);
+    roughness = mix(roughness, mail.roughness, mailBlend);
+
+    float metallic = leather.metallic;
+    metallic = mix(metallic, linen.metallic, linenBlend);
+    metallic = mix(metallic, scales.metallic, scaleBlend);
+    metallic = mix(metallic, mail.metallic, mailBlend);
+
+    float ao = leather.ao;
+    ao = mix(ao, linen.ao, linenBlend);
+    ao = mix(ao, scales.ao, scaleBlend);
+    ao = mix(ao, mail.ao, mailBlend);
+
+    vec3 F0 = leather.F0;
+    F0 = mix(F0, linen.F0, linenBlend);
+    F0 = mix(F0, scales.F0, scaleBlend);
+    F0 = mix(F0, mail.F0, mailBlend);
+
+    material.albedo = albedo;
+    material.normal = normal;
+    material.roughness = roughness;
+    material.metallic = metallic;
+    material.ao = ao;
+    material.F0 = F0;
   } else if (looks_wood) {
     vec3 wood_color = mix(base_color, CANON_WOOD, 0.35);
     material = make_wood_sample(wood_color, Nw, Tw, Bw, v_worldPos, wet_mask,
@@ -602,8 +677,9 @@ void main() {
     material = make_cloth_sample(base_color, Nw, Tw, Bw, v_worldPos, wet_mask,
                                  curvature);
   } else if (prefer_leather) {
+    vec3 leather_base = mix(base_color, vec3(0.44, 0.30, 0.19), 0.75);
     material = make_leather_sample(
-        base_color, Nw, Tw, Bw, v_worldPos, clamp(v_leatherTension, 0.0, 1.0),
+        leather_base, Nw, Tw, Bw, v_worldPos, clamp(v_leatherTension, 0.0, 1.0),
         clamp(v_bodyHeight, 0.0, 1.0), v_armorLayer, wet_mask, curvature);
   } else if (likely_linen) {
     material =
@@ -635,7 +711,7 @@ void main() {
   }
 
   vec3 ambient =
-      compute_ambient(material.normal) * material.albedo * material.ao * 0.35;
+      compute_ambient(material.normal) * material.albedo * material.ao * 0.42;
   vec3 bounce = vec3(0.45, 0.34, 0.25) *
                 (0.15 + 0.45 * clamp(-material.normal.y, 0.0, 1.0));
   vec3 color =

+ 14 - 14
assets/shaders/archer_carthage.vert

@@ -128,25 +128,25 @@ void main() {
   v_tangent = t;
   v_bitangent = b;
 
-  // Layer bands (kept identical thresholds to preserve your gameplay logic)
-  float height = offsetPos.y;
-  float layer = 2.0;
-  if (height > 1.28)
-    layer = 0.0;
-  else if (height > 0.86)
-    layer = 1.0;
-  v_armorLayer = layer;
+  // Layer bands from mesh-space height so placement does not change masks.
+  vec3 axisY = vec3(u_model[1].xyz);
+  float axisLen = max(length(axisY), 1e-4);
+  vec3 axisDir = axisY / axisLen;
+  vec3 modelOrigin = vec3(u_model[3].xyz);
+  float height01 =
+      clamp(dot(worldPos - modelOrigin, axisDir) / axisLen + 0.5, 0.0, 1.0);
+
+  // Use a single armor band for the whole piece to avoid partial masks.
+  float layer_mask = (u_materialId == 1) ? 1.0 : 0.0;
+  v_armorLayer = layer_mask;
 
   // Leather tension: variation + curvature bias + height influence
   float tensionSeed = hash13(offsetPos * 0.35 + worldNormal);
-  float heightFactor = smoothstep(0.5, 1.5, height);
+  float heightFactor = height01;
   float curvatureFactor = length(vec2(worldNormal.x, worldNormal.z));
-  v_leatherTension = mix(tensionSeed, 1.0 - tensionSeed, layer * 0.33) *
+  v_leatherTension = mix(tensionSeed, 1.0 - tensionSeed, layer_mask * 0.33) *
                      (0.7 + curvatureFactor * 0.3) * (0.8 + heightFactor * 0.2);
 
   // Normalized torso height for gradient effects
-  float torsoMin = 0.58;
-  float torsoMax = 1.36;
-  v_bodyHeight =
-      clamp((offsetPos.y - torsoMin) / (torsoMax - torsoMin), 0.0, 1.0);
+  v_bodyHeight = height01;
 }

+ 0 - 183
assets/shaders/archer_kingdom_of_iron.frag

@@ -1,183 +0,0 @@
-#version 330 core
-
-in vec3 v_normal;
-in vec2 v_texCoord;
-in vec3 v_worldPos;
-in float v_armorLayer; // Armor layer from vertex shader
-
-uniform sampler2D u_texture;
-uniform vec3 u_color;
-uniform bool u_useTexture;
-uniform float u_alpha;
-
-out vec4 FragColor;
-
-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);
-}
-
-// Quilted gambeson texture (padded cloth armor)
-float gambesonQuilt(vec2 p) {
-  // Diamond quilting pattern
-  vec2 grid = p * 12.0;
-  float diagA = sin((grid.x + grid.y) * 3.14159);
-  float diagB = sin((grid.x - grid.y) * 3.14159);
-  float quilt = diagA * diagB * 0.12;
-
-  // Stuffing bumps
-  float padding = noise(p * 18.0) * 0.10;
-
-  return quilt + padding;
-}
-
-// Riveted mail (lighter European chainmail)
-float rivetedMail(vec2 p) {
-  vec2 grid = fract(p * 28.0) - 0.5;
-  float ring = length(grid);
-  float ringPattern =
-      smoothstep(0.40, 0.35, ring) - smoothstep(0.30, 0.25, ring);
-
-  // Offset rows
-  vec2 offsetGrid = fract(p * 28.0 + vec2(0.5, 0.0)) - 0.5;
-  float offsetRing = length(offsetGrid);
-  float offsetPattern =
-      smoothstep(0.40, 0.35, offsetRing) - smoothstep(0.30, 0.25, offsetRing);
-
-  // Rivets on rings (more visible than Roman mail)
-  float rivet = noise(p * 30.0) * 0.06;
-
-  return (ringPattern + offsetPattern) * 0.16 + rivet;
-}
-
-// Wool/linen cloth texture
-float clothWeave(vec2 p) {
-  float warpThread = sin(p.x * 70.0);
-  float weftThread = sin(p.y * 68.0);
-  return warpThread * weftThread * 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;
-
-  // Detect materials by color and layer
-  bool isSteel = (avgColor > 0.55 && avgColor <= 0.75);
-  bool isGreen = (color.g > color.r * 1.15 && color.g > color.b * 1.05);
-  bool isBrown =
-      (color.r > color.g * 1.05 && color.r > color.b * 1.12 && avgColor < 0.60);
-  bool isHelmet = (v_armorLayer == 0.0);
-  bool isTorsoArmor = (v_armorLayer == 1.0);
-
-  // === KINGDOM/MEDIEVAL ARCHER (ENGLISH LONGBOWMAN STYLE) ===
-
-  // STEEL KETTLE HELMET or CHAPEL DE FER
-  if (isSteel && isHelmet) {
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.3))));
-    float steelSheen = pow(viewAngle, 8.0) * 0.28;
-    float steelFresnel = pow(1.0 - viewAngle, 2.5) * 0.22;
-
-    // Hammer marks and imperfections
-    float hammerMarks = noise(uv * 22.0) * 0.04;
-    float scratches = noise(uv * 35.0) * 0.03;
-
-    color += vec3(steelSheen + steelFresnel);
-    color += vec3(hammerMarks + scratches);
-  }
-  // PADDED GAMBESON (quilted cloth armor) - PRIMARY DEFENSE
-  else if (isTorsoArmor && (isBrown || avgColor > 0.45 && avgColor < 0.65)) {
-    // Thick quilted padding
-    float quilt = gambesonQuilt(v_worldPos.xz);
-
-    // Wool/linen texture
-    float weave = clothWeave(v_worldPos.xz);
-
-    // Soft matte finish (not shiny)
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.2))));
-    float clothSheen = pow(1.0 - viewAngle, 12.0) * 0.06;
-
-    // Natural wear and dirt
-    float wear = noise(uv * 6.0) * 0.12 - 0.06;
-
-    color *= 1.0 + quilt + weave - 0.04 + wear;
-    color += vec3(clothSheen);
-  }
-  // LIGHT MAIL SHIRT (optional, over gambeson)
-  else if (isTorsoArmor && isSteel) {
-    float mail = rivetedMail(v_worldPos.xz);
-
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.4))));
-    float mailSheen = pow(viewAngle, 6.0) * 0.18;
-
-    // Light rust
-    float rust = noise(uv * 11.0) * 0.06;
-
-    color += vec3(mail + mailSheen);
-    color -= vec3(rust * 0.3);
-  }
-  // GREEN TUNIC/HOOD (Lincoln green - iconic archer color)
-  else if (isGreen) {
-    float weave = clothWeave(v_worldPos.xz);
-    float woolFuzz = noise(uv * 22.0) * 0.09;
-
-    // Natural dye variations
-    float dyeVariation = noise(uv * 5.0) * 0.10;
-
-    // Soft cloth sheen
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.3))));
-    float clothSheen = pow(1.0 - viewAngle, 10.0) * 0.07;
-
-    color *= 1.0 + weave + woolFuzz - 0.03 + dyeVariation - 0.05;
-    color += vec3(clothSheen);
-  }
-  // LEATHER ELEMENTS (belt, bracers, boots)
-  else if (avgColor > 0.30 && avgColor <= 0.55) {
-    float leatherGrain = noise(uv * 14.0) * 0.16;
-    float tooling = noise(uv * 20.0) * 0.06;
-    float wear = noise(uv * 4.0) * 0.08 - 0.04;
-
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.4))));
-    float leatherSheen = pow(1.0 - viewAngle, 5.0) * 0.12;
-
-    color *= 1.0 + leatherGrain + tooling - 0.06 + wear;
-    color += vec3(leatherSheen);
-  }
-  // DARK ELEMENTS (boots, belts, straps)
-  else {
-    float darkDetail = noise(uv * 10.0) * 0.12;
-    float wear = noise(uv * 3.0) * 0.06;
-
-    color *= 1.0 + darkDetail - 0.08 - wear;
-  }
-
-  color = clamp(color, 0.0, 1.0);
-
-  // Lighting - softer for cloth/padding, harder for metal
-  vec3 lightDir = normalize(vec3(1.0, 1.2, 1.0));
-  float nDotL = dot(normal, lightDir);
-
-  // Cloth has more wrap-around lighting
-  float wrapAmount = (isSteel && isHelmet) ? 0.15 : 0.42;
-  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.24);
-
-  color *= diff;
-  FragColor = vec4(color, u_alpha);
-}

+ 0 - 31
assets/shaders/archer_kingdom_of_iron.vert

@@ -1,31 +0,0 @@
-#version 330 core
-
-layout(location = 0) in vec3 a_position;
-layout(location = 1) in vec3 a_normal;
-layout(location = 2) in vec2 a_texCoord;
-
-uniform mat4 u_mvp;
-uniform mat4 u_model;
-
-out vec3 v_normal;
-out vec2 v_texCoord;
-out vec3 v_worldPos;
-out float v_armorLayer; // Distinguish armor pieces for Kingdom archer
-
-void main() {
-  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
-  v_texCoord = a_texCoord;
-  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
-
-  // Detect armor layer based on Y position for Kingdom equipment
-  // Upper body (helmet) = 0, Torso (gambeson/mail) = 1, Lower (belt/skirt) = 2
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Kingdom helmet/coif region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Padded gambeson/light mail region
-  } else {
-    v_armorLayer = 2.0; // Belt/cloth skirt region
-  }
-
-  gl_Position = u_mvp * vec4(a_position, 1.0);
-}

+ 1 - 9
assets/shaders/archer_roman_republic.frag

@@ -130,13 +130,6 @@ void main() {
         cheek_guard_height * smoothstep(0.10, 0.08, cheek_x) * 0.35;
     float guard_edge = cheek_guard * step(0.32, noise(uv * 18.0)) * 0.18;
 
-    // ENHANCED: Neck guard (rear projection)
-    float neck_guard_height = smoothstep(0.68, 0.74, v_bodyHeight) *
-                              smoothstep(0.80, 0.74, v_bodyHeight);
-    float behind_head = step(v_worldNormal.z, -0.3); // Rear-facing normals
-    float neck_guard = neck_guard_height * behind_head * 0.28;
-    float neck_segments = fract(v_bodyHeight * 35.0) * neck_guard * 0.12;
-
     // ENHANCED: Bronze composition variation (copper/tin ratio affects color)
     float bronze_variation = noise(uv * 5.0) * 0.10;
     vec3 rich_bronze = vec3(0.82, 0.68, 0.42); // Higher copper
@@ -157,8 +150,7 @@ void main() {
     color = mix(color, rich_bronze, 0.6);
     color = mix(color, pale_bronze, bronze_variation);
     color += vec3(bronze_sheen + bronze_fresnel + bands + apex);
-    color += vec3(cheek_guard + guard_edge + neck_guard + neck_segments +
-                  plume_socket);
+    color += vec3(cheek_guard + guard_edge + plume_socket);
     color -= vec3(bronze_patina * 0.4 + verdigris * 0.3);
     color += vec3(hammer_marks * 0.5);
   }

+ 20 - 18
assets/shaders/archer_roman_republic.vert

@@ -105,15 +105,24 @@ void main() {
   vec4 modelPos = u_model * vec4(position, 1.0);
   vec3 worldPos = modelPos.xyz;
 
-  // Procedural denting and battle damage
-  float dentSeed = hash13(worldPos * 0.75 + worldNormal * 0.22);
-  float hammerNoise = sin(worldPos.y * 14.3 + dentSeed * 12.56);
-  vec3 dentOffset = worldNormal * ((dentSeed - 0.5) * 0.0095);
-  vec3 shearAxis = normalize(vec3(worldNormal.z, 0.18, -worldNormal.x));
-  vec3 shearOffset = shearAxis * hammerNoise * 0.0032;
-
-  vec3 batteredPos = worldPos + dentOffset + shearOffset;
-  vec3 offsetPos = batteredPos + worldNormal * 0.0055;
+  // Only add battle-wear deformation to armored pieces (not skin or cloth)
+  bool deformArmor = (u_materialId == 1 || u_materialId == 2 ||
+                      u_materialId == 4 || u_materialId == 3);
+
+  vec3 batteredPos = worldPos;
+  vec3 offsetPos = worldPos;
+
+  if (deformArmor) {
+    // Procedural denting and battle damage for armor/helmet/shield/weapons
+    float dentSeed = hash13(worldPos * 0.75 + worldNormal * 0.22);
+    float hammerNoise = sin(worldPos.y * 14.3 + dentSeed * 12.56);
+    vec3 dentOffset = worldNormal * ((dentSeed - 0.5) * 0.0095);
+    vec3 shearAxis = normalize(vec3(worldNormal.z, 0.18, -worldNormal.x));
+    vec3 shearOffset = shearAxis * hammerNoise * 0.0032;
+
+    batteredPos = worldPos + dentOffset + shearOffset;
+    offsetPos = batteredPos + worldNormal * 0.0055;
+  }
 
   mat4 invModel = inverse(u_model);
   vec4 localBattered = invModel * vec4(batteredPos, 1.0);
@@ -127,15 +136,8 @@ void main() {
   v_bitangent = b;
 
   float height = offsetPos.y;
-
-  // Armor layer detection - STRICT ranges to avoid applying to wrong body parts
-  if (height > 1.45) {
-    v_armorLayer = 0.0; // Helmet region (helmet mesh only)
-  } else if (height > 0.85 && height <= 1.45) {
-    v_armorLayer = 1.0; // Chainmail torso (armor mesh only)
-  } else {
-    v_armorLayer = 2.0; // Legs, pteruges, belt (non-armor)
-  }
+  // Keep armor/material selection stable: 1.0 only for armor material.
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
 
   // Body height normalization
   float torsoMin = 0.55;

+ 133 - 0
assets/shaders/catapult.frag

@@ -0,0 +1,133 @@
+#version 330 core
+
+// ============================================================================
+// CATAPULT FRAGMENT SHADER
+// Wood and metal materials for siege equipment
+// ============================================================================
+
+in vec3 v_normal;
+in vec2 v_texCoord;
+in vec3 v_worldPos;
+in float v_materialRegion;
+
+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);
+}
+
+// Wood grain pattern
+float wood_grain(vec2 p) {
+  float grain = sin(p.y * 30.0 + noise(p * 5.0) * 3.0) * 0.5 + 0.5;
+  float fine_grain = noise(p * 50.0) * 0.2;
+  return grain * 0.15 + fine_grain;
+}
+
+// Metal surface pattern
+float metal_surface(vec2 p) {
+  float scratches = noise(p * 80.0) * 0.1;
+  float polish = noise(p * 20.0) * 0.05;
+  return scratches + polish;
+}
+
+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.0;
+  float avg_color = (color.r + color.g + color.b) / 3.0;
+
+  // Detect material type from color
+  bool is_wood =
+      (color.r > color.b * 1.2 && avg_color > 0.25 && avg_color < 0.60);
+  bool is_metal =
+      (avg_color > 0.30 && avg_color < 0.55 && abs(color.r - color.g) < 0.1 &&
+       abs(color.g - color.b) < 0.1);
+  bool is_rope = (avg_color > 0.40 && avg_color < 0.65 && color.r > color.b);
+  bool is_leather =
+      (avg_color > 0.20 && avg_color < 0.45 && color.r > color.b * 1.3);
+
+  // === WOOD MATERIALS ===
+  if (is_wood) {
+    float grain = wood_grain(v_worldPos.xz);
+    float knots = step(0.92, noise(uv * 3.0)) * 0.15;
+
+    // Wood has subtle sheen along grain
+    float view_angle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.3))));
+    float wood_sheen = pow(1.0 - view_angle, 6.0) * 0.08;
+
+    color *= 1.0 + grain - knots;
+    color += vec3(wood_sheen);
+  }
+  // === METAL MATERIALS ===
+  else if (is_metal) {
+    float surface = metal_surface(v_worldPos.xz);
+
+    // Metal has stronger specular
+    float view_angle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.4))));
+    float metal_sheen = pow(1.0 - view_angle, 4.0) * 0.18;
+
+    // Slight rust/patina on exposed metal
+    float patina = noise(uv * 8.0) * 0.08;
+
+    color *= 1.0 + surface - patina;
+    color += vec3(metal_sheen);
+  }
+  // === ROPE MATERIALS ===
+  else if (is_rope) {
+    // Twisted fiber pattern
+    float twist = sin(v_worldPos.y * 40.0 + v_worldPos.x * 10.0) * 0.08;
+    float fiber = noise(uv * 60.0) * 0.12;
+
+    color *= 1.0 + twist + fiber - 0.05;
+  }
+  // === LEATHER MATERIALS ===
+  else if (is_leather) {
+    float grain = noise(uv * 20.0) * 0.15;
+    float crease = noise(uv * 8.0) * 0.10;
+
+    float view_angle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.3))));
+    float leather_sheen = pow(1.0 - view_angle, 5.0) * 0.10;
+
+    color *= 1.0 + grain - crease;
+    color += vec3(leather_sheen);
+  }
+  // === DEFAULT ===
+  else {
+    float detail = noise(uv * 15.0) * 0.08;
+    color *= 1.0 + detail - 0.04;
+  }
+
+  color = clamp(color, 0.0, 1.0);
+
+  // Lighting
+  vec3 light_dir = normalize(vec3(1.0, 1.2, 0.8));
+  float n_dot_l = dot(normal, light_dir);
+  float wrap = 0.35;
+  float diff = max(n_dot_l * (1.0 - wrap) + wrap, 0.30);
+
+  color *= diff;
+  FragColor = vec4(color, u_alpha);
+}

+ 35 - 0
assets/shaders/catapult.vert

@@ -0,0 +1,35 @@
+#version 330 core
+
+// ============================================================================
+// CATAPULT VERTEX SHADER
+// Simple vertex transformation for siege equipment
+// ============================================================================
+
+layout(location = 0) in vec3 a_position;
+layout(location = 1) in vec3 a_normal;
+layout(location = 2) in vec2 a_texCoord;
+
+uniform mat4 u_mvp;
+uniform mat4 u_model;
+
+out vec3 v_normal;
+out vec2 v_texCoord;
+out vec3 v_worldPos;
+out float v_materialRegion;
+
+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));
+
+  // Material region based on height (0=base/wheels, 1=frame, 2=arm/mechanism)
+  if (v_worldPos.y < 0.25) {
+    v_materialRegion = 0.0;
+  } else if (v_worldPos.y < 0.55) {
+    v_materialRegion = 1.0;
+  } else {
+    v_materialRegion = 2.0;
+  }
+
+  gl_Position = u_mvp * vec4(a_position, 1.0);
+}

+ 294 - 55
assets/shaders/healer_carthage.frag

@@ -1,9 +1,21 @@
 #version 330 core
 
+// ============================================================================
+// CARTHAGINIAN/PHOENICIAN HEALER SHADER
+// Mediterranean linen with Tyrian purple trim, leather craft, bronze tools,
+// and groomed beard shading focused on natural materials and soft cloth light
+// ============================================================================
+
 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;
@@ -13,6 +25,10 @@ uniform int u_materialId;
 
 out vec4 FragColor;
 
+// ============================================================================
+// UTILITY FUNCTIONS
+// ============================================================================
+
 float hash(vec2 p) {
   vec3 p3 = fract(vec3(p.xyx) * 0.1031);
   p3 += dot(p3, p3.yzx + 33.33);
@@ -30,84 +46,307 @@ float noise(vec2 p) {
   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;
+}
+
+// ============================================================================
+// MATERIAL DETAIL
+// ============================================================================
+
 float cloth_weave(vec2 p) {
-  float warp_thread = sin(p.x * 70.0);
-  float weft_thread = sin(p.y * 68.0);
-  return warp_thread * weft_thread * 0.06;
+  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 fine_thread = noise(p * 88.0) * 0.08;
-  float tyrian = noise(p * 12.0) * 0.05;
-  return weave + fine_thread + tyrian;
+  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;
+}
+
+float tyrian_dye_variation(vec2 p) {
+  float base_variation = noise(p * 5.5) * 0.22;
+  float marbling = fbm(p * 10.0) * 0.12;
+  float shellfish_pattern = noise(p * 18.0) * 0.06;
+  return base_variation + marbling + shellfish_pattern;
+}
+
+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));
+}
+
+// ============================================================================
+// LIGHTING HELPERS
+// ============================================================================
+
+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.62, 0.74, 0.88);
+  vec3 ground = vec3(0.38, 0.32, 0.26);
+  return sky * (0.28 + 0.50 * up) + ground * (0.12 + 0.32 * 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.55 + 0.45 * ao_strength) + light * ao_strength;
+}
+
+// ============================================================================
+// BEARD/FACIAL HAIR RENDERING
+// ============================================================================
+
+float beard_density(vec2 uv, vec3 worldPos) {
+  float strand_base = fbm(uv * 24.0) * 0.6;
+  float curl_pattern = sin(uv.x * 80.0 + noise(uv * 40.0) * 3.0) * 0.2;
+  float density_variation = noise(uv * 25.0) * 0.4;
+  float jaw_bias = smoothstep(1.36, 1.60, worldPos.y) * 0.25;
+  return strand_base + curl_pattern + density_variation + jaw_bias;
 }
 
+vec3 apply_beard_shading(vec3 base_skin, vec2 uv, vec3 normal, vec3 worldPos,
+                         vec3 V, vec3 L) {
+  vec3 beard_color = vec3(0.10, 0.07, 0.05);
+
+  float density = beard_density(uv, worldPos);
+
+  float chin_mask = smoothstep(1.55, 1.43, worldPos.y);
+  float jawline = smoothstep(1.48, 1.36, worldPos.y);
+  float beard_mask = clamp(chin_mask * 0.7 + jawline * 0.45, 0.0, 1.0);
+
+  float strand_highlight = pow(noise(uv * 220.0), 2.2) * 0.16;
+  float anisotropic =
+      pow(1.0 - abs(dot(normalize(normal + L * 0.28), V)), 7.0) * 0.10;
+  beard_color += vec3(strand_highlight + anisotropic);
+
+  return mix(base_skin, beard_color, density * beard_mask * 0.85);
+}
+
+// ============================================================================
+// MAIN FRAGMENT SHADER
+// ============================================================================
+
 void main() {
-  vec3 color = u_color;
+  vec3 base_color = u_color;
   if (u_useTexture) {
-    color *= texture(u_texture, v_texCoord).rgb;
+    base_color *= texture(u_texture, v_texCoord).rgb;
   }
 
-  vec3 normal = normalize(v_normal);
+  vec3 N = normalize(v_worldNormal);
+  vec3 T = normalize(v_tangent);
+  vec3 B = normalize(v_bitangent);
   vec2 uv = v_worldPos.xz * 4.5;
-  float avg_color = (color.r + color.g + color.b) / 3.0;
+  float avg_color = (base_color.r + base_color.g + base_color.b) / 3.0;
 
-  // Material ID: 0=body/skin, 1=armor, 2=helmet, 3=weapon, 4=shield
-  bool is_armor = (u_materialId == 1);
-  bool is_helmet = (u_materialId == 2);
-  bool is_weapon = (u_materialId == 3);
+  // Material ID: 0=body/skin, 1=tunic/robe, 2=purple trim, 3=leather, 4=tools
+  bool is_body = (u_materialId == 0);
+  bool is_tunic = (u_materialId == 1);
+  bool is_purple_trim = (u_materialId == 2);
+  bool is_leather = (u_materialId == 3);
+  bool is_tools = (u_materialId == 4);
 
-  // Use material IDs exclusively (no fallbacks)
+  // Fallback detection only if no material id provided
+  bool has_material_id = (u_materialId >= 0);
+  bool looks_light = (!has_material_id) && (avg_color > 0.72);
+  bool looks_purple =
+      (!has_material_id) && (base_color.b > base_color.g * 1.12 &&
+                             base_color.b > base_color.r * 1.05);
+  bool looks_skin =
+      (!has_material_id) && (avg_color > 0.45 && avg_color < 0.72 &&
+                             base_color.r > base_color.g * 0.95 &&
+                             base_color.r > base_color.b * 1.05);
 
-  bool is_light = (avg_color > 0.74);
-  bool is_purple = (color.b > color.g * 1.15 && color.b > color.r * 1.08);
+  vec3 V = normalize(vec3(-0.2, 1.0, 0.35));
+  vec3 L = normalize(vec3(1.0, 1.30, 0.8));
 
-  // LIGHT LINEN ROBES (Phoenician healer)
-  if (is_light || avg_color > 0.70) {
-    float linen = phoenician_linen(v_worldPos.xz);
-    float mediterranean_folds = noise(uv * 8.5) * 0.13;
+  float curvature = length(dFdx(N)) + length(dFdy(N));
+  float ao_folds =
+      clamp(1.0 - (v_clothFolds * 0.55 + curvature * 0.80), 0.25, 1.0);
+  float dust_mask = smoothstep(0.22, 0.0, v_bodyHeight);
+  float sun_bleach = smoothstep(0.55, 1.05, v_bodyHeight) * 0.07;
 
-    float view_angle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.2))));
-    float linen_sheen = pow(1.0 - view_angle, 9.0) * 0.13;
+  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;
 
-    color *= 1.0 + linen + mediterranean_folds - 0.02;
-    color += vec3(linen_sheen);
-  }
-  // PURPLE CAPE/TRIM (Tyrian purple - rare healer dye)
-  else if (is_purple) {
-    float weave = cloth_weave(v_worldPos.xz);
-    float tyrian_richness = noise(uv * 6.0) * 0.15;
-    float luxury_shimmer = noise(uv * 35.0) * 0.04;
+  // === CARTHAGINIAN HEALER MATERIALS ===
+  if (is_tunic || looks_light) {
+    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);
 
-    float view_angle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.3))));
-    float silk_sheen = pow(1.0 - view_angle, 6.0) * 0.14;
+    N_used = perturb_cloth_normal(N, T, B, uv, 128.0, 116.0, 0.08);
 
-    color *= 1.0 + weave + tyrian_richness + luxury_shimmer - 0.03;
-    color += vec3(silk_sheen);
-  }
-  // LEATHER ELEMENTS (sandals, belt)
-  else if (avg_color > 0.30 && avg_color <= 0.56) {
-    float leather_grain = noise(uv * 14.0) * 0.14;
-    float phoenician_craft = noise(uv * 24.0) * 0.06;
+    albedo = mix(base_color, vec3(0.93, 0.89, 0.82), 0.55);
+    albedo *= 1.0 + linen + weave * 0.08 - drape_folds;
+    albedo += vec3(0.02, 0.015, 0.0) * sun_bleach;
+    albedo -= vec3(dust * 0.25);
 
-    float view_angle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.4))));
-    float leather_sheen = pow(1.0 - view_angle, 6.0) * 0.10;
+    roughness = 0.72 - clamp(v_fabricWear * 0.08, 0.0, 0.12);
+    sheen = 0.08 + clamp(v_bodyHeight * 0.04, 0.0, 0.06);
+    ao *= 1.0 - dust * 0.30;
+    wrap = 0.54;
+  } else if (is_purple_trim || looks_purple) {
+    float dye = tyrian_dye_variation(uv);
+    float silk = noise(uv * 52.0) * 0.06;
+    float thread_ridge = cloth_weave(uv * 1.1);
 
-    color *= 1.0 + leather_grain + phoenician_craft - 0.04;
-    color += vec3(leather_sheen);
-  } else {
-    float detail = noise(uv * 10.0) * 0.10;
-    color *= 1.0 + detail - 0.06;
-  }
+    N_used = perturb_cloth_normal(N, T, B, uv, 150.0, 142.0, 0.05);
+
+    albedo = mix(base_color, vec3(0.32, 0.10, 0.44), 0.40);
+    albedo *= 1.0 + dye + silk + thread_ridge;
+    albedo += vec3(0.03, 0.0, 0.05) * clamp(dot(N, V), 0.0, 1.0);
+
+    roughness = 0.42;
+    sheen = 0.16;
+    metallic = 0.05;
+    wrap = 0.48;
+  } else if (is_leather || (avg_color > 0.28 && avg_color <= 0.52)) {
+    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);
+
+    albedo = mix(base_color, vec3(0.44, 0.30, 0.18), 0.20);
+    albedo *= 1.0 + leather_grain + craft_detail - 0.04;
+    albedo += vec3(stitching + edge_wear);
+
+    roughness = 0.55 - clamp(v_fabricWear * 0.05, 0.0, 0.10);
+    sheen = 0.10;
+    wrap = 0.46;
+  } else if (is_body || looks_skin) {
+    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));
 
-  color = clamp(color, 0.0, 1.0);
+    albedo *= 1.0 + skin_detail;
+    albedo += vec3(0.03, 0.015, 0.0) * subdermal;
 
-  vec3 light_dir = normalize(vec3(1.0, 1.2, 1.0));
-  float n_dot_l = dot(normal, light_dir);
-  float wrap_amount = 0.46;
-  float diff = max(n_dot_l * (1.0 - wrap_amount) + wrap_amount, 0.28);
+    bool is_face_region = (v_worldPos.y > 1.40 && v_worldPos.y < 1.65);
+    if (is_face_region) {
+      albedo = apply_beard_shading(albedo, uv, N_used, v_worldPos, V, L);
+    }
+
+    float rim = pow(1.0 - clamp(dot(N_used, V), 0.0, 1.0), 4.0) * 0.05;
+    albedo += vec3(rim);
+
+    roughness = 0.55;
+    sheen = 0.06 + subdermal * 0.2;
+    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);
+
+    albedo = mix(base_color, vec3(0.72, 0.52, 0.28), 0.65);
+    albedo -= vec3(patina * 0.24);
+    albedo += vec3(edge_polish);
+
+    roughness = 0.30 + patina * 0.12;
+    metallic = 0.92;
+    sheen = 0.12;
+    wrap = 0.42;
+  } else {
+    float detail = noise(uv * 12.0) * 0.10;
+    albedo *= 1.0 + detail - 0.05;
+  }
 
-  color *= diff;
-  FragColor = vec4(color, u_alpha);
+  vec3 color = apply_lighting(albedo, N_used, V, L, roughness, metallic, ao,
+                              sheen, wrap);
+  FragColor = vec4(clamp(color, 0.0, 1.0), u_alpha);
 }

+ 64 - 10
assets/shaders/healer_carthage.vert

@@ -1,29 +1,83 @@
 #version 330 core
 
+// ============================================================================
+// CARTHAGINIAN HEALER VERTEX SHADER
+// Flowing Mediterranean robes with natural draping
+// ============================================================================
+
 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() {
-  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
+  vec3 position = a_position;
+  vec3 normal = a_normal;
+
+  mat3 normalMatrix = mat3(transpose(inverse(u_model)));
+  vec3 worldNormal = normalize(normalMatrix * normal);
+
+  // Build tangent space for detailed shading
+  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(a_position, 1.0));
+  v_worldPos = vec3(u_model * vec4(position, 1.0));
+
+  // Body height for cloth flow (0.0 = feet, 1.0 = head)
+  v_bodyHeight = clamp((v_worldPos.y + 0.2) / 1.8, 0.0, 1.0);
+
+  // Phoenician robes drape differently - looser, more flowing folds
+  // Emphasis on chest/waist gather and lower hem flow
+  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);
+
+  // Fabric wear pattern - more at edges and stress points
+  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;
 
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0;
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0;
-  } else {
-    v_armorLayer = 2.0;
-  }
+  // Keep material selection stable; armor layer only toggles for armor.
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
 
-  gl_Position = u_mvp * vec4(a_position, 1.0);
+  gl_Position = u_mvp * vec4(position, 1.0);
 }

+ 0 - 103
assets/shaders/healer_kingdom_of_iron.frag

@@ -1,103 +0,0 @@
-#version 330 core
-
-in vec3 v_normal;
-in vec2 v_texCoord;
-in vec3 v_worldPos;
-in float v_armorLayer;
-
-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 linenTexture(vec2 p) {
-  float weave = clothWeave(p);
-  float fineThread = noise(p * 85.0) * 0.08;
-  return weave + fineThread;
-}
-
-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;
-
-  bool isWhite = (avgColor > 0.75);
-  bool isGreen = (color.g > color.r * 1.12 && color.g > color.b * 1.08);
-  bool isHelmet = (v_armorLayer == 0.0);
-  bool isTorso = (v_armorLayer == 1.0);
-
-  // WHITE/LIGHT CLOTH (healer robes)
-  if (isWhite || avgColor > 0.70) {
-    float linen = linenTexture(v_worldPos.xz);
-    float folds = noise(uv * 8.0) * 0.14;
-
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.2))));
-    float silkSheen = pow(1.0 - viewAngle, 8.0) * 0.15;
-
-    color *= 1.0 + linen + folds - 0.02;
-    color += vec3(silkSheen);
-  }
-  // GREEN HEALING CAPE/TRIM
-  else if (isGreen) {
-    float weave = clothWeave(v_worldPos.xz);
-    float healingPattern = noise(uv * 6.0) * 0.12;
-
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.3))));
-    float clothSheen = pow(1.0 - viewAngle, 9.0) * 0.10;
-
-    color *= 1.0 + weave + healingPattern - 0.03;
-    color += vec3(clothSheen);
-  }
-  // LIGHT LEATHER ELEMENTS
-  else if (avgColor > 0.30 && avgColor <= 0.55) {
-    float leatherGrain = noise(uv * 14.0) * 0.14;
-    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 + leatherGrain - 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);
-}

+ 0 - 29
assets/shaders/healer_kingdom_of_iron.vert

@@ -1,29 +0,0 @@
-#version 330 core
-
-layout(location = 0) in vec3 a_position;
-layout(location = 1) in vec3 a_normal;
-layout(location = 2) in vec2 a_texCoord;
-
-uniform mat4 u_mvp;
-uniform mat4 u_model;
-
-out vec3 v_normal;
-out vec2 v_texCoord;
-out vec3 v_worldPos;
-out float v_armorLayer;
-
-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));
-
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0;
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0;
-  } else {
-    v_armorLayer = 2.0;
-  }
-
-  gl_Position = u_mvp * vec4(a_position, 1.0);
-}

+ 280 - 101
assets/shaders/healer_roman_republic.frag

@@ -1,5 +1,11 @@
 #version 330 core
 
+// ============================================================================
+// ROMAN MEDICUS (HEALER) SHADER
+// Clean, practical Roman medical professional appearance with crisp textiles,
+// maintained leather, polished bronze tools, and soft wrap lighting.
+// ============================================================================
+
 // ============================================================================
 // INPUTS & OUTPUTS
 // ============================================================================
@@ -44,20 +50,142 @@ float noise(vec2 p) {
   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;
+}
+
 // ============================================================================
-// MATERIAL PATTERN FUNCTIONS (Roman Medicus)
+// ROMAN TEXTILE PATTERNS
 // ============================================================================
 
 float cloth_weave(vec2 p) {
-  float warp_thread = sin(p.x * 70.0);
-  float weft_thread = sin(p.y * 68.0);
-  return warp_thread * weft_thread * 0.06;
+  // Tight Roman linen weave
+  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) {
+  // Fine bleached Roman linen - crisp and clean
   float weave = cloth_weave(p);
-  float fine_thread = noise(p * 90.0) * 0.07;
-  return weave + fine_thread;
+  float fine_thread = noise(p * 95.0) * 0.06;
+  float slub = fbm(p * 7.5) * 0.05;
+  return weave + fine_thread + slub;
+}
+
+float roman_wool(vec2 p) {
+  // Coarser wool for cape/sash - more texture
+  float coarse_weave = sin(p.x * 55.0) * sin(p.y * 52.0) * 0.08;
+  float fiber_variation = noise(p * 65.0) * 0.09;
+  float nap = fbm(p * 9.0) * 0.05;
+  return coarse_weave + fiber_variation + nap;
+}
+
+// ============================================================================
+// PERTURBED NORMALS
+// ============================================================================
+
+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_wool_normal(vec3 N, vec3 T, vec3 B, vec2 uv) {
+  float weave = sin(uv.x * 58.0) * 0.08 + sin(uv.y * 56.0) * 0.08;
+  float fuzz = fbm(uv * 12.0) * 0.06;
+  return normalize(N + T * (weave + fuzz * 0.6) + B * (weave * 0.6 + fuzz));
+}
+
+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));
+}
+
+// ============================================================================
+// LIGHTING HELPERS
+// ============================================================================
+
+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;
 }
 
 // ============================================================================
@@ -65,131 +193,182 @@ float roman_linen(vec2 p) {
 // ============================================================================
 
 void main() {
-  vec3 color = u_color;
+  vec3 base_color = u_color;
   if (u_useTexture) {
-    color *= texture(u_texture, v_texCoord).rgb;
+    base_color *= texture(u_texture, v_texCoord).rgb;
   }
 
-  vec3 normal = normalize(v_normal);
+  vec3 N = normalize(v_worldNormal);
+  vec3 T = normalize(v_tangent);
+  vec3 B = normalize(v_bitangent);
   vec2 uv = v_worldPos.xz * 4.5;
+  float avg_color = (base_color.r + base_color.g + base_color.b) / 3.0;
 
   // Material ID: 0=body/skin, 1=tunica, 2=leather, 3=medical tools, 4=red
-  // trim/cape
+  // trim/sash
   bool is_body = (u_materialId == 0);
   bool is_tunica = (u_materialId == 1);
   bool is_leather = (u_materialId == 2);
   bool is_medical_tools = (u_materialId == 3);
   bool is_red_trim = (u_materialId == 4);
 
-  // Use material IDs exclusively (no fallbacks)
+  // Only fall back to color heuristics if material id is absent/invalid
+  bool has_material_id = (u_materialId >= 0);
+  bool looks_light = (!has_material_id) && (avg_color > 0.75);
+  bool looks_red = (!has_material_id) && (base_color.r > base_color.g * 1.8 &&
+                                          base_color.r > base_color.b * 2.0);
+  bool looks_brown =
+      (!has_material_id) &&
+      (avg_color > 0.25 && avg_color < 0.55 && base_color.r > base_color.b);
+
+  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;
+
+  // WHITE/CREAM LINEN TUNICA (main garment - bleached Roman style)
+  if (is_tunica || looks_light) {
+    vec3 tunic_base = vec3(0.95, 0.93, 0.90);
+    albedo = tunic_base;
 
-  // === ROMAN MEDICUS MATERIALS ===
-
-  // WHITE LINEN TUNICA (main garment)
-  if (is_tunica) {
-    // Fine linen weave with natural texture
     float linen = roman_linen(v_worldPos.xz);
-    float fine_thread = noise(uv * 95.0) * 0.06;
+    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;
 
-    // Cloth folds from vertex shader (natural draping)
-    float fold_depth = v_clothFolds * noise(uv * 12.0) * 0.18;
+    float dust =
+        smoothstep(0.24, 0.0, v_bodyHeight) * (0.10 + noise(uv * 6.5) * 0.10);
 
-    // Wear patterns on high-stress areas (elbows, knees)
-    float wear_pattern = v_fabricWear * noise(uv * 8.0) * 0.12;
+    N_used = perturb_linen_normal(N, T, B, uv);
 
-    // Natural linen has subtle sheen (different from silk)
-    vec3 V = normalize(vec3(0.0, 1.0, 0.2));
-    float view_angle = max(dot(normalize(v_worldNormal), V), 0.0);
-    float linen_sheen = pow(1.0 - view_angle, 10.0) * 0.12;
+    float view_angle = max(dot(N_used, V), 0.0);
+    float linen_sheen = pow(1.0 - view_angle, 11.0) * 0.14;
 
-    // Slight discoloration from use (natural aging)
-    float aging = noise(uv * 3.0) * 0.08;
+    float aging = noise(uv * 2.5) * 0.06;
+    vec3 age_tint = vec3(0.02, 0.01, -0.01) * aging;
 
-    color *= 1.0 + linen + fine_thread - 0.03;
-    color -= vec3(fold_depth + wear_pattern + aging);
-    color += vec3(linen_sheen);
+    albedo *= 1.0 + linen + fine_thread - 0.025;
+    albedo -= vec3(fold_depth + wear_pattern);
+    albedo += vec3(linen_sheen);
+    albedo += age_tint;
+    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;
   }
-  // RED WOOL CAPE/TRIM (military medicus insignia)
-  else if (is_red_trim) {
-    // Wool weave (coarser than linen)
-    float weave = cloth_weave(v_worldPos.xz);
-    float wool_tex = noise(uv * 55.0) * 0.10;
-
-    // Natural madder root dye richness variation
-    float dye_richness = noise(uv * 5.0) * 0.14;
-
-    // Wool has different sheen than linen (more matte)
-    vec3 V = normalize(vec3(0.0, 1.0, 0.3));
-    float view_angle = max(dot(normalize(v_worldNormal), V), 0.0);
-    float wool_sheen = pow(1.0 - view_angle, 7.0) * 0.11;
-
-    // Fading from sun exposure (outer garment)
-    float sun_fading = smoothstep(0.8, 1.0, v_bodyHeight) * 0.08;
-
-    color *= 1.0 + weave + wool_tex + dye_richness - 0.04;
-    color += vec3(wool_sheen);
-    color -= vec3(sun_fading);
+  // RED WOOL SASH/TRIM (military medicus identification)
+  else if (is_red_trim || looks_red) {
+    float weave = roman_wool(v_worldPos.xz);
+    float wool_tex = noise(uv * 58.0) * 0.10;
+
+    float dye_richness = noise(uv * 4.5) * 0.15;
+    float dye_depth = noise(uv * 8.0) * 0.08;
+
+    float view_angle = max(dot(N, V), 0.0);
+    float wool_sheen = pow(1.0 - view_angle, 6.0) * 0.09;
+
+    float sun_fading = smoothstep(0.75, 1.0, v_bodyHeight) * 0.07;
+    float wash_fade = noise(uv * 3.0) * 0.05;
+
+    N_used = perturb_wool_normal(N, T, B, uv);
+
+    albedo = mix(base_color, vec3(0.75, 0.12, 0.12), 0.35);
+    albedo *= 1.0 + weave + wool_tex + dye_richness + dye_depth - 0.04;
+    albedo += vec3(wool_sheen);
+    albedo -= vec3(sun_fading + wash_fade);
+
+    roughness = 0.50;
+    sheen = 0.10;
+    wrap = 0.48;
   }
-  // LEATHER EQUIPMENT (bag, belt, sandals, straps)
-  else if (is_leather) {
-    // Vegetable-tanned leather grain
-    float leather_grain = noise(uv * 15.0) * 0.15 * (1.0 + v_fabricWear * 0.3);
-    float pores = noise(uv * 35.0) * 0.07;
-
-    // Roman tooling and stitching marks
-    float tooling = noise(uv * 22.0) * 0.05;
-    float stitching = step(0.95, fract(v_worldPos.x * 15.0)) *
-                      step(0.95, fract(v_worldPos.y * 12.0)) * 0.08;
-
-    // Leather darkens and stiffens with age/oil
-    float oil_darkening = v_fabricWear * 0.15;
-
-    // Subtle leather sheen (not glossy, just slight reflection)
-    vec3 V = normalize(vec3(0.0, 1.0, 0.4));
-    float view_angle = max(dot(normalize(v_worldNormal), V), 0.0);
-    float leather_sheen = pow(1.0 - view_angle, 5.5) * 0.11;
-
-    // Edge wear (lighter color at stressed edges)
-    float edge_wear =
-        smoothstep(0.88, 0.92, abs(dot(normal, v_tangent))) * 0.10;
-
-    color *= 1.0 + leather_grain + pores + tooling - oil_darkening;
-    color += vec3(stitching + leather_sheen + edge_wear);
+  // LEATHER EQUIPMENT (medical bag, belt, sandals, straps)
+  else if (is_leather || looks_brown) {
+    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;
   }
-  // MEDICAL IMPLEMENTS (bronze/iron tools)
+  // BRONZE MEDICAL IMPLEMENTS
   else if (is_medical_tools) {
-    // Bronze medical instruments (Roman surgical tools were bronze)
-    vec3 bronze_base = vec3(0.75, 0.55, 0.30);
+    vec3 bronze_base = vec3(0.76, 0.56, 0.32);
 
-    // Tool patina from use and cleaning
-    float patina = noise(uv * 12.0) * 0.18;
-    float verdigris = noise(uv * 18.0) * 0.10;
+    float patina = noise(uv * 13.0) * 0.16;
+    float verdigris = noise(uv * 20.0) * 0.08;
 
-    // Polished areas from frequent handling
-    vec3 V = normalize(vec3(0.0, 1.0, 0.5));
-    float view_angle = max(dot(normalize(v_worldNormal), V), 0.0);
-    float bronze_sheen = pow(view_angle, 8.0) * 0.35;
+    float view_angle = max(dot(N, V), 0.0);
+    float bronze_sheen = pow(view_angle, 9.0) * 0.38;
 
-    color = mix(color, bronze_base, 0.6);
-    color -= vec3(patina * 0.3 + verdigris * 0.2);
-    color += vec3(bronze_sheen);
+    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, bronze_base, 0.58);
+    albedo -= vec3(patina * 0.28 + verdigris * 0.18);
+    albedo += vec3(bronze_sheen + edge_polish);
+
+    roughness = 0.32 + patina * 0.12;
+    metallic = 0.92;
+    sheen = 0.12;
+    wrap = 0.42;
   }
-  // BODY/SKIN (hands, face, neck)
+  // BODY/SKIN (Roman - clean-shaven face, hands, arms)
   else if (is_body) {
-    // Minimal processing for skin - let base color through
-    float skin_detail = noise(uv * 25.0) * 0.08;
-    color *= 1.0 + skin_detail;
-  }
+    float skin_detail = noise(uv * 28.0) * 0.07;
+    float skin_subsurface = noise(uv * 8.0) * 0.04;
 
-  color = clamp(color, 0.0, 1.0);
+    N_used = normalize(N + vec3(0.0, 0.01, 0.0) *
+                               triplanar_noise(v_worldPos * 3.0, N, 5.5));
 
-  // Lighting model (softer for cloth-based unit)
-  vec3 light_dir = normalize(vec3(1.0, 1.2, 1.0));
-  float n_dot_l = dot(normal, light_dir);
+    albedo *= 1.0 + skin_detail;
+    albedo += vec3(0.02, 0.01, 0.0) * skin_subsurface;
 
-  float wrap_amount = is_tunica ? 0.50 : (is_red_trim ? 0.48 : 0.42);
-  float diff = max(n_dot_l * (1.0 - wrap_amount) + wrap_amount, 0.25);
+    float rim = pow(1.0 - clamp(dot(N_used, V), 0.0, 1.0), 4.0) * 0.04;
+    albedo += vec3(rim);
+
+    roughness = 0.54;
+    sheen = 0.05;
+    wrap = 0.46;
+  }
+  // DEFAULT (catch-all)
+  else {
+    float detail = noise(uv * 11.0) * 0.09;
+    albedo *= 1.0 + detail - 0.05;
+  }
 
-  color *= diff;
-  FragColor = vec4(color, u_alpha);
+  vec3 color = apply_lighting(albedo, N_used, V, L, roughness, metallic, ao,
+                              sheen, wrap);
+  FragColor = vec4(clamp(color, 0.0, 1.0), u_alpha);
 }

+ 2 - 8
assets/shaders/healer_roman_republic.vert

@@ -63,14 +63,8 @@ void main() {
   // Fabric wear pattern (procedural based on world position)
   v_fabricWear = hash13(v_worldPos * 0.5) * 0.3 + 0.2; // 0.2-0.5 range
 
-  // Legacy armor layer for fallback compatibility
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0;
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0;
-  } else {
-    v_armorLayer = 2.0;
-  }
+  // Armor selection based solely on material ID.
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
 
   gl_Position = u_mvp * vec4(position, 1.0);
 }

+ 46 - 3
assets/shaders/horse_archer_carthage.frag

@@ -177,8 +177,8 @@ void main() {
 
   // Material-based detection only (no fallbacks)
   bool is_brass = is_helmet;
-  bool is_steel = is_armor;
-  bool is_chain = is_armor;
+  bool is_steel = false;
+  bool is_chain = false;
   bool is_fabric = is_rider_clothing || is_saddle_blanket || is_cloak;
   bool is_leather = is_saddle_leather || is_bridle;
 
@@ -214,7 +214,50 @@ void main() {
   vec3 col = vec3(0.0);
   vec3 ambient = hemilight(N) * (0.85 + 0.15 * cavity);
 
-  if (is_horse_hide) {
+  if (is_armor) {
+    // Leather-first mix, consistent with infantry light armor
+    float leather_grain = fbm(uv * 12.0) * 0.12;
+    float linen_weave = fbm(uv * 6.0) * 0.08;
+    float scale_hint = armor_plates(v_worldPos.xz, v_worldPos.y) * 0.35;
+
+    float h = fbm(vec2(v_worldPos.y * 22.0, v_worldPos.z * 7.0));
+    N = perturb_normal_ws(N, v_worldPos, h, 0.30);
+
+    vec3 leather_tint = vec3(0.44, 0.30, 0.19);
+    vec3 linen_tint = vec3(0.86, 0.80, 0.72);
+    vec3 bronze_tint = vec3(0.62, 0.46, 0.20);
+    vec3 chain_tint = vec3(0.78, 0.80, 0.82);
+
+    // Treat the entire armor mesh as torso to avoid clipping by height bands.
+    float torsoBand = 1.0;
+    float skirtBand = 0.0;
+    float linenBlend = skirtBand * 0.40;
+    float bronzeBlend = torsoBand * 0.45;
+    float chainBlend = torsoBand * 0.20;
+    float leatherOverlay = skirtBand * 0.90 + torsoBand * 0.30;
+    float edge = 1.0 - clamp(dot(N, vec3(0.0, 1.0, 0.0)), 0.0, 1.0);
+    vec3 highlight = vec3(0.10, 0.08, 0.05) * smoothstep(0.3, 0.9, edge);
+
+    albedo = leather_tint;
+    albedo = mix(albedo, linen_tint, linenBlend);
+    albedo = mix(albedo, bronze_tint, bronzeBlend);
+    albedo = mix(albedo, chain_tint, chainBlend);
+    albedo = mix(albedo, leather_tint + highlight, leatherOverlay);
+
+    roughness = 0.42 - leather_grain * 0.12 + linen_weave * 0.08;
+    roughness = clamp(roughness, 0.26, 0.62);
+    F0 = mix(vec3(0.06), bronze_tint, 0.22);
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnel_schlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * albedo * 0.70;
+    col += NdotL_wrap * (albedo * (1.0 - F) + spec * 0.65);
+    col += vec3(scale_hint * 0.4 + leather_grain * 0.2 + linen_weave * 0.15);
+
+  } else if (is_horse_hide) {
     // subtle anisotropic sheen along body flow
     vec3 up = vec3(0.0, 1.0, 0.0);
     vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess

+ 2 - 10
assets/shaders/horse_archer_carthage.vert

@@ -67,16 +67,8 @@ void main() {
   vec4 model_pos = u_model * vec4(pos, 1.0);
   v_worldPos = model_pos.xyz;
 
-  // Detect armor layer based on Y position for Carthaginian Numidian cavalry
-  // Upper body (helmet) = 0, Torso (light armor/cloak) = 1, Lower (bare
-  // legs/horse) = 2
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Bronze cap/no helmet region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Light tunic/imported mail region
-  } else {
-    v_armorLayer = 2.0; // Bare legs/simple saddle blanket region
-  }
+  // Keep armor material consistent: 1.0 means armor in the fragment shader.
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
 
   gl_Position = u_mvp * vec4(pos, 1.0);
 }

+ 0 - 354
assets/shaders/horse_archer_kingdom_of_iron.frag

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

+ 0 - 32
assets/shaders/horse_archer_kingdom_of_iron.vert

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

+ 2 - 8
assets/shaders/horse_archer_roman_republic.vert

@@ -112,14 +112,8 @@ void main() {
   v_hairFlow = hashVal * 0.5 + 0.5;
   v_hoofWear = hashVal * 0.3;
 
-  // Legacy armor layer for fallback
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Helmet region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Torso armor region
-  } else {
-    v_armorLayer = 2.0; // Legs/horse region
-  }
+  // Armor selection based solely on material ID.
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
 
   gl_Position = u_mvp * vec4(position, 1.0);
 }

+ 51 - 3
assets/shaders/horse_spearman_carthage.frag

@@ -175,8 +175,8 @@ void main() {
 
   // Material-based detection only (no fallbacks)
   bool is_brass = is_helmet;
-  bool is_steel = is_armor;
-  bool is_chain = is_armor;
+  bool is_chain = false;
+  bool is_steel = false;
   bool is_fabric = is_rider_clothing || is_saddle_blanket;
   bool is_leather = is_saddle_leather || is_bridle;
 
@@ -212,7 +212,55 @@ void main() {
   vec3 col = vec3(0.0);
   vec3 ambient = hemilight(N) * (0.85 + 0.15 * cavity);
 
-  if (is_horse_hide) {
+  if (is_armor) {
+    // Leather-first mix with subtle bronze and linen to match infantry
+    float leather_grain = fbm(uv * 12.0) * 0.12;
+    float linen_weave = fbm(uv * 6.0) * 0.08;
+    float scale_hint = armor_plates(v_worldPos.xz, v_worldPos.y) * 0.35;
+
+    float h = fbm(vec2(v_worldPos.y * 22.0, v_worldPos.z * 7.0));
+    N = perturb_normal_ws(N, v_worldPos, h, 0.30);
+
+    vec3 leather_tint = vec3(0.44, 0.30, 0.19);
+    vec3 linen_tint = vec3(0.86, 0.80, 0.72);
+    vec3 bronze_tint = vec3(0.62, 0.46, 0.20);
+    vec3 chain_tint = vec3(0.78, 0.80, 0.82);
+
+    // Treat the entire armor mesh as torso to avoid clipping by height bands.
+    float torsoBand = 1.0;
+    float skirtBand = 0.0;
+    float linenBlend = skirtBand * 0.40;
+    float bronzeBlend = torsoBand * 0.45;
+    float chainBlend = torsoBand * 0.20;
+    float leatherOverlay = skirtBand * 0.90 + torsoBand * 0.30;
+    float edge = 1.0 - clamp(dot(N, vec3(0.0, 1.0, 0.0)), 0.0, 1.0);
+    vec3 highlight = vec3(0.10, 0.08, 0.05) * smoothstep(0.3, 0.9, edge);
+
+    albedo = leather_tint;
+    albedo = mix(albedo, linen_tint, linenBlend);
+    albedo = mix(albedo, bronze_tint, bronzeBlend);
+    albedo = mix(albedo, chain_tint, chainBlend);
+    albedo = mix(albedo, leather_tint + highlight, leatherOverlay);
+
+    float leather_depth = clamp(
+        leatherOverlay * 0.8 + linenBlend * 0.2 + bronzeBlend * 0.15, 0.0, 1.0);
+    albedo = mix(albedo, albedo * 0.88 + vec3(0.04, 0.03, 0.02),
+                 leather_depth * 0.35);
+
+    roughness = 0.42 - leather_grain * 0.12 + linen_weave * 0.08;
+    roughness = clamp(roughness, 0.26, 0.62);
+    F0 = mix(vec3(0.06), bronze_tint, 0.22);
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnel_schlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * albedo * 0.70;
+    col += NdotL_wrap * (albedo * (1.0 - F) + spec * 0.65);
+    col += vec3(scale_hint * 0.4 + leather_grain * 0.2 + linen_weave * 0.15);
+
+  } else if (is_horse_hide) {
     // subtle anisotropic sheen along body flow
     vec3 up = vec3(0.0, 1.0, 0.0);
     vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess

+ 2 - 10
assets/shaders/horse_spearman_carthage.vert

@@ -18,16 +18,8 @@ void main() {
   v_texCoord = a_texCoord;
   v_worldPos = vec3(u_model * vec4(a_position, 1.0));
 
-  // Detect armor layer based on Y position for Carthaginian Numidian cavalry
-  // Upper body (helmet) = 0, Torso (light armor/cloak) = 1, Lower (bare
-  // legs/horse) = 2
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Bronze cap/no helmet region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Light tunic/imported mail region
-  } else {
-    v_armorLayer = 2.0; // Bare legs/simple saddle blanket region
-  }
+  // Keep armor material consistent: 1.0 means armor in the fragment shader.
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
 
   gl_Position = u_mvp * vec4(a_position, 1.0);
 }

+ 0 - 354
assets/shaders/horse_spearman_kingdom_of_iron.frag

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

+ 0 - 32
assets/shaders/horse_spearman_kingdom_of_iron.vert

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

+ 2 - 8
assets/shaders/horse_spearman_roman_republic.vert

@@ -67,14 +67,8 @@ void main() {
   v_hairFlow = hashVal * 0.5 + 0.5;
   v_hoofWear = hashVal * 0.3;
 
-  // Legacy armor layer for fallback
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Helmet region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Torso armor region
-  } else {
-    v_armorLayer = 2.0; // Legs/horse region
-  }
+  // Armor selection based solely on material ID.
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
 
   gl_Position = u_mvp * vec4(position, 1.0);
 }

+ 58 - 4
assets/shaders/horse_swordsman_carthage.frag

@@ -161,7 +161,7 @@ void main() {
   // 5=rider clothing, 6=horse hide, 7=horse mane, 8=horse hoof,
   // 9=saddle leather, 10=bridle, 11=saddle blanket
   bool is_rider_skin = (u_materialId == 0);
-  bool is_armor = (u_materialId == 1);
+  bool is_body_armor = (u_materialId == 1);
   bool is_helmet = (u_materialId == 2);
   bool is_weapon = (u_materialId == 3);
   bool is_shield = (u_materialId == 4);
@@ -175,8 +175,8 @@ void main() {
 
   // Material-based detection only (no fallbacks)
   bool is_brass = is_helmet;
-  bool is_steel = is_armor;
-  bool is_chain = is_armor;
+  bool is_steel = false;
+  bool is_chain = false;
   bool is_fabric = is_rider_clothing || is_saddle_blanket;
   bool is_leather = is_saddle_leather || is_bridle;
 
@@ -212,7 +212,61 @@ void main() {
   vec3 col = vec3(0.0);
   vec3 ambient = hemilight(N) * (0.85 + 0.15 * cavity);
 
-  if (is_horse_hide) {
+  if (is_body_armor) {
+    // Bronze + chain + linen mix to match infantry look.
+    float brushed =
+        abs(sin(v_worldPos.y * 55.0)) * 0.02 + noise(uv * 28.0) * 0.015;
+    float plates = armor_plates(v_worldPos.xz, v_worldPos.y);
+    float rings = chainmail_rings(v_worldPos.xz);
+    float linen = fbm(uv * 5.0);
+
+    // bump from light hammering
+    float h = fbm(vec2(v_worldPos.y * 18.0, v_worldPos.z * 6.0));
+    N = perturb_normal_ws(N, v_worldPos, h, 0.32);
+
+    vec3 bronze_tint = vec3(0.62, 0.46, 0.20);
+    vec3 steel_tint = vec3(0.68, 0.70, 0.74);
+    vec3 linen_tint = vec3(0.86, 0.80, 0.72);
+    vec3 leather_tint = vec3(0.38, 0.25, 0.15);
+
+    // Treat entire armor mesh as torso to avoid height-based clipping.
+    float torsoBand = 1.0;
+    float skirtBand = 0.0;
+    float mailBlend =
+        clamp(smoothstep(0.15, 0.85, rings + cavity * 0.25), 0.15, 1.0) *
+        torsoBand;
+    float cuirassBlend = torsoBand;
+    float leatherBlend = skirtBand * 0.65;
+    float linenBlend = skirtBand * 0.45;
+
+    vec3 bronze = mix(bronze_tint, base_color, 0.40);
+    vec3 chain_col = mix(steel_tint, base_color, 0.25);
+    vec3 linen_col = mix(linen_tint, base_color, 0.20);
+    vec3 leather_col = mix(leather_tint, base_color, 0.30);
+
+    albedo = bronze;
+    albedo = mix(albedo, chain_col, mailBlend);
+    albedo = mix(albedo, linen_col, linenBlend);
+    albedo = mix(albedo, leather_col, leatherBlend);
+
+    // bias toward brighter metal luma
+    float armor_luma = dot(albedo, vec3(0.299, 0.587, 0.114));
+    albedo = mix(albedo, albedo * 1.20, smoothstep(0.30, 0.65, armor_luma));
+
+    roughness = 0.32 + brushed * 1.2;
+    roughness = clamp(roughness, 0.18, 0.55);
+    F0 = mix(vec3(0.74), albedo, 0.25);
+
+    float D = D_GGX(saturate(dot(N, H)), roughness);
+    float G = G_Smith(saturate(dot(N, V)), saturate(dot(N, L)), roughness);
+    vec3 F = fresnel_schlick(VdotH, F0);
+    vec3 spec = (D * G) * F / max(1e-5, 4.0 * NdotV * NdotL);
+
+    col += ambient * mix(vec3(1.0), albedo, 0.25);
+    col += NdotL_wrap * (spec * 1.35);
+    col += vec3(plates) * 0.35 + vec3(rings * 0.25) + vec3(linen * linenBlend);
+
+  } else if (is_horse_hide) {
     // subtle anisotropic sheen along body flow
     vec3 up = vec3(0.0, 1.0, 0.0);
     vec3 T = normalize(cross(up, N) + 1e-4); // hair tangent guess

+ 2 - 10
assets/shaders/horse_swordsman_carthage.vert

@@ -18,16 +18,8 @@ void main() {
   v_texCoord = a_texCoord;
   v_worldPos = vec3(u_model * vec4(a_position, 1.0));
 
-  // Detect armor layer based on Y position for Carthaginian Numidian cavalry
-  // Upper body (helmet) = 0, Torso (light armor/cloak) = 1, Lower (bare
-  // legs/horse) = 2
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Bronze cap/no helmet region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Light tunic/imported mail region
-  } else {
-    v_armorLayer = 2.0; // Bare legs/simple saddle blanket region
-  }
+  // Keep armor material consistent: 1.0 means armor in the fragment shader.
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
 
   gl_Position = u_mvp * vec4(a_position, 1.0);
 }

+ 0 - 354
assets/shaders/horse_swordsman_kingdom_of_iron.frag

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

+ 0 - 32
assets/shaders/horse_swordsman_kingdom_of_iron.vert

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

+ 2 - 8
assets/shaders/horse_swordsman_roman_republic.vert

@@ -67,14 +67,8 @@ void main() {
   v_hairFlow = hashVal * 0.5 + 0.5;
   v_hoofWear = hashVal * 0.3;
 
-  // Legacy armor layer for fallback
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Helmet region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Torso armor region
-  } else {
-    v_armorLayer = 2.0; // Legs/horse region
-  }
+  // Armor selection based solely on material ID.
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
 
   gl_Position = u_mvp * vec4(position, 1.0);
 }

+ 39 - 20
assets/shaders/spearman_carthage.frag

@@ -324,44 +324,63 @@ void main() {
   if (is_helmet) {
     mat = sample_bronze_helmet(base_color, v_worldPos, Nw, Tw, Bw);
   } else if (is_armor) {
+    vec3 leather_base = vec3(0.44, 0.30, 0.19);
+    vec3 linen_base = vec3(0.86, 0.80, 0.72);
+    vec3 bronze_base = vec3(0.62, 0.46, 0.20);
+    vec3 chain_base = vec3(0.78, 0.80, 0.82);
+
+    MaterialSample leather =
+        sample_leather(leather_base, v_worldPos * 0.85, Nw, Tw, Bw);
     MaterialSample linen =
-        sample_linen(base_color, v_worldPos * 1.1, Nw, Tw, Bw);
+        sample_linen(linen_base, v_worldPos * 1.0, Nw, Tw, Bw);
     MaterialSample scales =
-        sample_bronze_scales(base_color, v_worldPos * 1.0, Nw, Tw, Bw);
+        sample_bronze_scales(bronze_base, v_worldPos * 0.95, Nw, Tw, Bw);
     MaterialSample mail =
-        sample_chainmail(base_color, v_worldPos * 0.9, Nw, Tw, Bw);
-    MaterialSample leather =
-        sample_leather(base_color, v_worldPos * 0.8, Nw, Tw, Bw);
+        sample_chainmail(chain_base, v_worldPos * 0.9, Nw, Tw, Bw);
 
-    float torsoBand = 1.0 - step(1.5, v_armorLayer);
-    float skirtBand = step(1.0, v_armorLayer);
+    // Treat the entire armor piece as torso to avoid losing coverage to height
+    // band thresholds.
+    float torsoBand = 1.0;
+    float skirtBand = 0.0;
     float mailBlend =
         clamp(smoothstep(0.25, 0.85, v_chainmailPhase + v_steelWear * 0.35),
               0.0, 1.0) *
-        torsoBand;
-    float scaleBlend = clamp(0.35 + v_steelWear * 0.6, 0.0, 1.0) * torsoBand;
-    float leatherBlend = skirtBand * 0.75;
-
-    // Blend linen base with bronze scales and chainmail overlays
-    mat.color = linen.color;
+        torsoBand * 0.30;
+    float scaleBlend =
+        clamp(0.28 + v_steelWear * 0.55, 0.0, 1.0) * torsoBand * 0.55;
+    float linenBlend = skirtBand * 0.40;
+    float leatherOverlay = skirtBand * 0.90 + torsoBand * 0.30;
+
+    // subtle edge tint to lift highlights
+    float edge = 1.0 - clamp(dot(Nw, vec3(0.0, 1.0, 0.0)), 0.0, 1.0);
+    vec3 highlight = vec3(0.10, 0.08, 0.05) * smoothstep(0.3, 0.9, edge);
+
+    // Leather-first blend with lighter linen skirt and subtle bronze/chain
+    mat.color = leather.color;
+    mat.color = mix(mat.color, linen.color, linenBlend);
     mat.color = mix(mat.color, scales.color, scaleBlend);
     mat.color = mix(mat.color, mail.color, mailBlend);
-    mat.color = mix(mat.color, leather.color, leatherBlend);
+    mat.color = mix(mat.color, leather.color + highlight, leatherOverlay);
+
+    float leather_depth = clamp(
+        leatherOverlay * 0.8 + linenBlend * 0.2 + scaleBlend * 0.15, 0.0, 1.0);
+    mat.color = mix(mat.color, mat.color * 0.88 + vec3(0.04, 0.03, 0.02),
+                    leather_depth * 0.35);
 
-    mat.normal = linen.normal;
+    mat.normal = leather.normal;
+    mat.normal = normalize(mix(mat.normal, linen.normal, linenBlend));
     mat.normal = normalize(mix(mat.normal, scales.normal, scaleBlend));
     mat.normal = normalize(mix(mat.normal, mail.normal, mailBlend));
-    mat.normal = normalize(mix(mat.normal, leather.normal, leatherBlend));
 
-    mat.roughness = linen.roughness;
+    mat.roughness = leather.roughness;
+    mat.roughness = mix(mat.roughness, linen.roughness, linenBlend);
     mat.roughness = mix(mat.roughness, scales.roughness, scaleBlend);
     mat.roughness = mix(mat.roughness, mail.roughness, mailBlend);
-    mat.roughness = mix(mat.roughness, leather.roughness, leatherBlend);
 
-    mat.F0 = linen.F0;
+    mat.F0 = leather.F0;
+    mat.F0 = mix(mat.F0, linen.F0, linenBlend);
     mat.F0 = mix(mat.F0, scales.F0, scaleBlend);
     mat.F0 = mix(mat.F0, mail.F0, mailBlend);
-    mat.F0 = mix(mat.F0, leather.F0, leatherBlend);
   } else if (is_weapon) {
     if (v_bodyHeight > 0.55) {
       mat = sample_steel(base_color, v_worldPos * 1.4, Nw, Tw, Bw);

+ 39 - 26
assets/shaders/spearman_carthage.vert

@@ -60,16 +60,38 @@ void main() {
 
   vec4 modelPos = u_model * vec4(position, 1.0);
   vec3 worldPos = modelPos.xyz;
-
-  // Subtle battered offset to avoid self-shadowing
-  float dentSeed = hash13(worldPos * 0.82 + worldNormal * 0.28);
-  float hammerImpact = sin(worldPos.y * 15.0 + dentSeed * 18.84);
-  vec3 dentOffset = worldNormal * ((dentSeed - 0.5) * 0.0095);
-  vec3 shearAxis = normalize(vec3(worldNormal.z, 0.22, -worldNormal.x));
-  vec3 shearOffset = shearAxis * hammerImpact * 0.0038;
-
-  vec3 batteredPos = worldPos + dentOffset + shearOffset;
-  vec3 offsetPos = batteredPos + worldNormal * 0.006;
+  vec3 axisY = vec3(u_model[1].xyz);
+  float axisLen = max(length(axisY), 1e-4);
+  vec3 axisDir = axisY / axisLen;
+  vec3 modelOrigin = vec3(u_model[3].xyz);
+  float height01 =
+      clamp(dot(worldPos - modelOrigin, axisDir) / axisLen + 0.5, 0.0, 1.0);
+
+  // Only deform armored pieces (helmet/armor/shield/weapons) and only within
+  // their height bands to avoid touching skin/face
+  bool torsoZone = (height01 > 0.18 && height01 <= 0.98);
+  bool helmetZone = (height01 > 0.70); // allow small overlap for helmet rim
+  bool deformArmor =
+      (u_materialId == 4 || u_materialId == 3 || // shields, weapons
+       (u_materialId == 1 && torsoZone) ||       // chainmail
+       (u_materialId == 2 && helmetZone));       // helmet
+
+  float dentSeed = 0.0;
+  float hammerImpact = 0.0;
+  vec3 batteredPos = worldPos;
+  vec3 offsetPos = worldPos;
+
+  if (deformArmor) {
+    // Subtle battered offset to avoid self-shadowing
+    dentSeed = hash13(worldPos * 0.82 + worldNormal * 0.28);
+    hammerImpact = sin(worldPos.y * 15.0 + dentSeed * 18.84);
+    vec3 dentOffset = worldNormal * ((dentSeed - 0.5) * 0.0095);
+    vec3 shearAxis = normalize(vec3(worldNormal.z, 0.22, -worldNormal.x));
+    vec3 shearOffset = shearAxis * hammerImpact * 0.0038;
+
+    batteredPos = worldPos + dentOffset + shearOffset;
+    offsetPos = batteredPos + worldNormal * 0.006;
+  }
 
   mat4 invModel = inverse(u_model);
   vec4 localOffset = invModel * vec4(offsetPos, 1.0);
@@ -82,28 +104,19 @@ void main() {
   v_tangent = t;
   v_bitangent = b;
 
-  float height = offsetPos.y;
+  float height = height01;
 
-  // Armor segmentation (helmet / torso / lower)
-  if (height > 1.50) {
-    v_armorLayer = 0.0;
-  } else if (height > 0.85 && height <= 1.50) {
-    v_armorLayer = 1.0;
-  } else {
-    v_armorLayer = 2.0;
-  }
+  // Keep armor material active across the whole piece; avoid partial bands.
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
 
-  float torsoMin = 0.55;
-  float torsoMax = 1.68;
-  v_bodyHeight =
-      clamp((offsetPos.y - torsoMin) / (torsoMax - torsoMin), 0.0, 1.0);
+  v_bodyHeight = clamp((height - 0.05) / 0.90, 0.0, 1.0);
 
   // Helmet detail bands and rivets
-  float reinforcementBands = fract(height * 14.0);
+  float reinforcementBands = fract(height * 12.0);
   float browBandRegion =
-      smoothstep(1.48, 1.52, height) * smoothstep(1.56, 1.52, height);
+      smoothstep(0.78, 0.82, height) * smoothstep(0.90, 0.86, height);
   float cheekGuardArea =
-      smoothstep(1.45, 1.55, height) * smoothstep(1.65, 1.55, height);
+      smoothstep(0.70, 0.82, height) * smoothstep(0.90, 0.82, height);
   v_helmetDetail =
       reinforcementBands * 0.4 + browBandRegion * 0.4 + cheekGuardArea * 0.2;
 

+ 0 - 328
assets/shaders/spearman_kingdom_of_iron.frag

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

+ 0 - 31
assets/shaders/spearman_kingdom_of_iron.vert

@@ -1,31 +0,0 @@
-#version 330 core
-
-layout(location = 0) in vec3 a_position;
-layout(location = 1) in vec3 a_normal;
-layout(location = 2) in vec2 a_texCoord;
-
-uniform mat4 u_mvp;
-uniform mat4 u_model;
-
-out vec3 v_normal;
-out vec2 v_texCoord;
-out vec3 v_worldPos;
-out float v_armorLayer; // Distinguish armor pieces for Kingdom spearman
-
-void main() {
-  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
-  v_texCoord = a_texCoord;
-  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
-
-  // Detect armor layer based on Y position for Kingdom pike infantry
-  // Upper body (helmet) = 0, Torso (brigandine/mail) = 1, Lower (tassets) = 2
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Kingdom kettle helm/bascinet region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Brigandine/mail hauberk region
-  } else {
-    v_armorLayer = 2.0; // Tassets/faulds region
-  }
-
-  gl_Position = u_mvp * vec4(a_position, 1.0);
-}

+ 1 - 9
assets/shaders/spearman_roman_republic.frag

@@ -119,14 +119,6 @@ void main() {
     float guard_repousse =
         cheek_guard * noise(uv * 12.0) * 0.08; // Decorative embossing
 
-    // ENHANCED: Neck guard (segmented rear protection)
-    float neck_guard_height = smoothstep(0.66, 0.72, v_bodyHeight) *
-                              smoothstep(0.82, 0.72, v_bodyHeight);
-    float behind_head = step(v_worldNormal.z, -0.25);
-    float neck_guard = neck_guard_height * behind_head * 0.32;
-    float neck_segments =
-        step(0.85, fract(v_bodyHeight * 42.0)) * neck_guard * 0.15;
-
     // ENHANCED: Brow reinforcement (frontal impact protection)
     float brow_height = smoothstep(0.84, 0.88, v_bodyHeight) *
                         smoothstep(0.92, 0.88, v_bodyHeight);
@@ -145,7 +137,7 @@ void main() {
 
     color += vec3(steel_sheen + steel_fresnel + bands + rivets);
     color += vec3(cheek_guard + hinge_pins + guard_repousse);
-    color += vec3(neck_guard + neck_segments + brow_reinforce + plume_socket);
+    color += vec3(brow_reinforce + plume_socket);
     color -= vec3(rust_tex * 0.3);
     color += vec3(brushed * 0.6);
   }

+ 22 - 18
assets/shaders/spearman_roman_republic.vert

@@ -61,15 +61,26 @@ void main() {
   vec4 modelPos = u_model * vec4(position, 1.0);
   vec3 worldPos = modelPos.xyz;
 
-  // Heavy steel battle damage simulation
-  float dentSeed = hash13(worldPos * 0.82 + worldNormal * 0.28);
-  float hammerImpact = sin(worldPos.y * 16.5 + dentSeed * 18.84);
-  vec3 dentOffset = worldNormal * ((dentSeed - 0.5) * 0.0115); // Deeper dents
-  vec3 shearAxis = normalize(vec3(worldNormal.z, 0.22, -worldNormal.x));
-  vec3 shearOffset = shearAxis * hammerImpact * 0.0042;
-
-  vec3 batteredPos = worldPos + dentOffset + shearOffset;
-  vec3 offsetPos = batteredPos + worldNormal * 0.006;
+  // Restrict deformation to hard surfaces (armor, helmet, shield, weapons)
+  bool deformArmor = (u_materialId == 1 || u_materialId == 2 ||
+                      u_materialId == 4 || u_materialId == 3);
+
+  float dentSeed = 0.0;
+  float hammerImpact = 0.0;
+  vec3 batteredPos = worldPos;
+  vec3 offsetPos = worldPos;
+
+  if (deformArmor) {
+    // Heavy steel battle damage simulation (skip skin/cloth)
+    dentSeed = hash13(worldPos * 0.82 + worldNormal * 0.28);
+    hammerImpact = sin(worldPos.y * 16.5 + dentSeed * 18.84);
+    vec3 dentOffset = worldNormal * ((dentSeed - 0.5) * 0.0115);
+    vec3 shearAxis = normalize(vec3(worldNormal.z, 0.22, -worldNormal.x));
+    vec3 shearOffset = shearAxis * hammerImpact * 0.0042;
+
+    batteredPos = worldPos + dentOffset + shearOffset;
+    offsetPos = batteredPos + worldNormal * 0.006;
+  }
 
   mat4 invModel = inverse(u_model);
   vec4 localBattered = invModel * vec4(batteredPos, 1.0);
@@ -83,15 +94,8 @@ void main() {
   v_bitangent = b;
 
   float height = offsetPos.y;
-
-  // Armor layer detection - STRICT torso range only
-  if (height > 1.50) {
-    v_armorLayer = 0.0; // Heavy steel helmet
-  } else if (height > 0.85 && height <= 1.50) {
-    v_armorLayer = 1.0; // Light chainmail (pectorale) - TORSO ONLY
-  } else {
-    v_armorLayer = 2.0; // Leather pteruges
-  }
+  // Keep armor/material selection stable: 1.0 only for armor material.
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
 
   // Body height normalization
   float torsoMin = 0.55;

+ 3 - 2
assets/shaders/swordsman_carthage.frag

@@ -367,8 +367,9 @@ void main() {
     MaterialSample leather =
         sample_leather(leather_base, v_worldPos * 0.9, Nw, Tw, Bw);
 
-    float torsoBand = 1.0 - step(1.5, v_armorLayer);
-    float skirtBand = step(1.0, v_armorLayer);
+    // Treat the entire armor piece as torso to avoid missing coverage.
+    float torsoBand = 1.0;
+    float skirtBand = 0.0;
     float mailBlend =
         clamp(smoothstep(0.15, 0.78, v_chainmailMix + v_layerNoise * 0.25),
               0.15, 1.0) *

+ 13 - 13
assets/shaders/swordsman_carthage.vert

@@ -52,6 +52,15 @@ void main() {
   vec4 modelPos = u_model * vec4(position, 1.0);
   vec3 worldPos = modelPos.xyz;
 
+  // Normalize height along the model's local Y so lighting gradients stay
+  // stable regardless of placement or per-piece transforms.
+  vec3 axisY = vec3(u_model[1].xyz);
+  float axisLen = max(length(axisY), 1e-4);
+  vec3 axisDir = axisY / axisLen;
+  vec3 modelOrigin = vec3(u_model[3].xyz);
+  float height01 =
+      clamp(dot(worldPos - modelOrigin, axisDir) / axisLen + 0.5, 0.0, 1.0);
+
   float dentSeed = hash13(worldPos * 0.8 + worldNormal * 0.3);
   float torsion = 0.0; // removed bulk shear to avoid squashing
 
@@ -83,19 +92,10 @@ void main() {
 
   v_frontMask = clamp(smoothstep(-0.18, 0.18, -localPos.z), 0.0, 1.0);
 
-  float armorHeight = offsetPos.y;
-  if (armorHeight > 1.5) {
-    v_armorLayer = 0.0;
-  } else if (armorHeight > 0.8) {
-    v_armorLayer = 1.0;
-  } else {
-    v_armorLayer = 2.0;
-  }
-
-  float torsoMin = 0.58;
-  float torsoMax = 1.50;
-  v_bodyHeight =
-      clamp((offsetPos.y - torsoMin) / (torsoMax - torsoMin), 0.0, 1.0);
+  // Force the full armor piece to use the armor material; avoid partial masks.
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
+
+  v_bodyHeight = clamp((height01 - 0.05) / 0.90, 0.0, 1.0);
   v_layerNoise = dentSeed;
   v_plateStress = torsion;
   v_lamellaPhase = fract(offsetPos.y * 4.25 + offsetPos.x * 0.12);

+ 0 - 180
assets/shaders/swordsman_kingdom_of_iron.frag

@@ -1,180 +0,0 @@
-#version 330 core
-
-in vec3 v_normal;
-in vec2 v_texCoord;
-in vec3 v_worldPos;
-in float v_armorLayer; // Armor layer from vertex shader
-
-uniform sampler2D u_texture;
-uniform vec3 u_color;
-uniform bool u_useTexture;
-uniform float u_alpha;
-
-out vec4 FragColor;
-
-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);
-}
-
-// Medieval plate armor articulation lines
-float armorPlates(vec2 p, float y) {
-  // Horizontal articulation lines (overlapping plates)
-  float plateY = fract(y * 6.5);
-  float plateLine = smoothstep(0.90, 0.98, plateY) * 0.12;
-
-  // Brass rivet decorations
-  float rivetX = fract(p.x * 18.0);
-  float rivet = smoothstep(0.48, 0.50, rivetX) * smoothstep(0.52, 0.50, rivetX);
-  float rivetPattern = rivet * step(0.92, plateY) * 0.25; // Brass is brighter
-
-  return plateLine + rivetPattern;
-}
-
-// Chainmail texture pattern
-float chainmailRings(vec2 p) {
-  vec2 grid = fract(p * 35.0) - 0.5;
-  float ring = length(grid);
-  float ringPattern =
-      smoothstep(0.35, 0.30, ring) - smoothstep(0.25, 0.20, ring);
-
-  // Offset every other row for interlinked appearance
-  vec2 offsetGrid = fract(p * 35.0 + vec2(0.5, 0.0)) - 0.5;
-  float offsetRing = length(offsetGrid);
-  float offsetPattern =
-      smoothstep(0.35, 0.30, offsetRing) - smoothstep(0.25, 0.20, offsetRing);
-
-  return (ringPattern + offsetPattern) * 0.15;
-}
-
-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 * 5.0;
-  float avgColor = (color.r + color.g + color.b) / 3.0;
-
-  // Detect material type by color tone
-  float colorHue =
-      max(max(color.r, color.g), color.b) - min(min(color.r, color.g), color.b);
-  bool isBrass =
-      (color.r > color.g * 1.08 && color.r > color.b * 1.15 && avgColor > 0.50);
-
-  // === MEDIEVAL KNIGHT MATERIALS ===
-
-  // POLISHED STEEL PLATE (Great Helm, cuirass, pauldrons, rerebraces) - bright
-  // silvery
-  if (avgColor > 0.60 && !isBrass) {
-    // Mirror-polished steel finish
-    float brushedMetal = abs(sin(v_worldPos.y * 90.0)) * 0.022;
-
-    // Battle wear: scratches and dents
-    float scratches = noise(uv * 32.0) * 0.020;
-    float dents = noise(uv * 7.0) * 0.024;
-
-    // Plate articulation lines and rivets
-    float plates = armorPlates(v_worldPos.xz, v_worldPos.y);
-
-    // Strong specular reflections (polished metal)
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.55))));
-    float fresnel = pow(1.0 - viewAngle, 1.6) * 0.33;
-    float specular = pow(viewAngle, 11.0) * 0.58;
-
-    // Environmental reflections (sky dome)
-    float skyReflection = (normal.y * 0.5 + 0.5) * 0.15;
-
-    color += vec3(fresnel + skyReflection + specular * 1.8);
-    color += vec3(plates);
-    color += vec3(brushedMetal);
-    color -= vec3(scratches + dents * 0.4);
-  }
-  // BRASS ACCENTS (rivets, buckles, crosses, decorations) - golden
-  else if (isBrass) {
-    // Warm metallic brass
-    float brassNoise = noise(uv * 22.0) * 0.025;
-    float patina = noise(uv * 6.0) * 0.08; // Age darkening
-
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
-    float brassSheen = pow(viewAngle, 7.5) * 0.32;
-    float brassFresnel = pow(1.0 - viewAngle, 2.2) * 0.22;
-
-    color += vec3(brassSheen + brassFresnel);
-    color += vec3(brassNoise);
-    color -= vec3(patina * 0.5); // Darker in recesses
-  }
-  // CHAINMAIL AVENTAIL (hanging neck protection) - grey steel rings
-  else if (avgColor > 0.40 && avgColor <= 0.60) {
-    // Interlocked ring texture
-    float rings = chainmailRings(v_worldPos.xz);
-
-    // Chainmail has less shine than plate
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
-    float chainSheen = pow(viewAngle, 6.0) * 0.18;
-
-    // Individual ring highlights
-    float ringHighlights = noise(uv * 30.0) * 0.12;
-
-    color += vec3(rings + chainSheen + ringHighlights);
-    color *= 1.0 - noise(uv * 12.0) * 0.08; // Slight darkening between rings
-  }
-  // HERALDIC SURCOAT (team-colored tabard over armor) - bright cloth
-  else if (avgColor > 0.25) {
-    // Rich fabric weave texture
-    float weaveX = sin(v_worldPos.x * 70.0);
-    float weaveZ = sin(v_worldPos.z * 70.0);
-    float weave = weaveX * weaveZ * 0.04;
-
-    // Embroidered cross emblem texture
-    float embroidery = noise(uv * 12.0) * 0.06;
-
-    // Fabric has soft sheen
-    float viewAngle = abs(dot(normal, normalize(vec3(0.0, 1.0, 0.5))));
-    float fabricSheen = pow(1.0 - viewAngle, 6.0) * 0.08;
-
-    // Heraldic colors are vibrant
-    color *= 1.0 + noise(uv * 5.0) * 0.10 - 0.05;
-    color += vec3(weave + embroidery + fabricSheen);
-  }
-  // LEATHER/DARK ELEMENTS (straps, gloves, scabbard) - dark brown
-  else {
-    float leatherGrain = noise(uv * 10.0) * 0.15;
-    float wearMarks = noise(uv * 3.0) * 0.10;
-
-    color *= 1.0 + leatherGrain - 0.08 + wearMarks - 0.05;
-  }
-
-  float crestBlend = pow(1.0 - abs(normal.y), 2.0) * 0.12;
-  color = mix(color, vec3(0.58, 0.62, 0.80), crestBlend);
-  color = clamp(color, 0.0, 1.0);
-
-  // Lighting model - hard shadows for metal, soft for fabric
-  vec3 lightDir = normalize(vec3(1.0, 1.2, 1.0));
-  float nDotL = dot(normal, lightDir);
-
-  // Metal = hard shadows, Fabric = soft wrap
-  float wrapAmount = (avgColor > 0.50) ? 0.08 : 0.30;
-  float diff = max(nDotL * (1.0 - wrapAmount) + wrapAmount, 0.18);
-
-  // Extra contrast for polished steel
-  if (avgColor > 0.60 && !isBrass) {
-    diff = pow(diff, 0.85); // Sharper lighting falloff
-  }
-
-  color *= diff;
-  FragColor = vec4(color, u_alpha);
-}

+ 0 - 31
assets/shaders/swordsman_kingdom_of_iron.vert

@@ -1,31 +0,0 @@
-#version 330 core
-
-layout(location = 0) in vec3 a_position;
-layout(location = 1) in vec3 a_normal;
-layout(location = 2) in vec2 a_texCoord;
-
-uniform mat4 u_mvp;
-uniform mat4 u_model;
-
-out vec3 v_normal;
-out vec2 v_texCoord;
-out vec3 v_worldPos;
-out float v_armorLayer; // Distinguish armor pieces for Kingdom swordsman
-
-void main() {
-  v_normal = mat3(transpose(inverse(u_model))) * a_normal;
-  v_texCoord = a_texCoord;
-  v_worldPos = vec3(u_model * vec4(a_position, 1.0));
-
-  // Detect armor layer based on Y position for Kingdom heavy swordsman
-  // Upper body (helmet) = 0, Torso (plate/mail) = 1, Lower (greaves/belt) = 2
-  if (v_worldPos.y > 1.5) {
-    v_armorLayer = 0.0; // Kingdom great helm region
-  } else if (v_worldPos.y > 0.8) {
-    v_armorLayer = 1.0; // Heavy plate/chainmail region
-  } else {
-    v_armorLayer = 2.0; // Cuisses/greaves region
-  }
-
-  gl_Position = u_mvp * vec4(a_position, 1.0);
-}

+ 7 - 2
assets/shaders/swordsman_roman_republic.frag

@@ -388,12 +388,17 @@ void main() {
   color = mix(color, mud_color, mud_splatter);
   color = mix(color, blood_color, blood_stain);
 
+  // Subtle ambient occlusion to ground the metal and leather
+  float ao = 0.90 - noise(v_worldPos.xz * 4.0) * 0.10;
+  color *= ao;
+  color = mix(color, vec3(dot(color, vec3(0.333))), 0.08); // mild desaturation
+
   // Lighting per material
   vec3 light_dir = normalize(vec3(1.0, 1.2, 1.0));
   float n_dot_l = dot(normalize(v_worldNormal), light_dir);
 
-  float wrap_amount = is_helmet ? 0.08 : (is_armor ? 0.10 : 0.30);
-  float diff = max(n_dot_l * (1.0 - wrap_amount) + wrap_amount, 0.18);
+  float wrap_amount = is_helmet ? 0.08 : (is_armor ? 0.08 : 0.30);
+  float diff = max(n_dot_l * (1.0 - wrap_amount) + wrap_amount, 0.16);
 
   // Extra contrast for polished steel
   if (is_helmet || is_armor) {

+ 22 - 18
assets/shaders/swordsman_roman_republic.vert

@@ -64,15 +64,26 @@ void main() {
   vec4 modelPos = u_model * vec4(position, 1.0);
   vec3 worldPos = modelPos.xyz;
 
-  // Heavy battle damage for elite legionary equipment
-  float dentSeed = hash13(worldPos * 0.88 + worldNormal * 0.32);
-  float combatStress = sin(worldPos.y * 18.2 + dentSeed * 22.61);
-  vec3 dentOffset = worldNormal * ((dentSeed - 0.5) * 0.0105);
-  vec3 shearAxis = normalize(vec3(worldNormal.z, 0.25, -worldNormal.x));
-  vec3 shearOffset = shearAxis * combatStress * 0.0038;
-
-  vec3 batteredPos = worldPos + dentOffset + shearOffset;
-  vec3 offsetPos = batteredPos + worldNormal * 0.0062;
+  // Deform only armored pieces (not face/skin/clothing)
+  bool deformArmor = (u_materialId == 1 || u_materialId == 2 ||
+                      u_materialId == 4 || u_materialId == 3);
+
+  float dentSeed = 0.0;
+  float combatStress = 0.0;
+  vec3 batteredPos = worldPos;
+  vec3 offsetPos = worldPos;
+
+  if (deformArmor) {
+    // Heavy battle damage for elite legionary equipment
+    dentSeed = hash13(worldPos * 0.88 + worldNormal * 0.32);
+    combatStress = sin(worldPos.y * 18.2 + dentSeed * 22.61);
+    vec3 dentOffset = worldNormal * ((dentSeed - 0.5) * 0.0105);
+    vec3 shearAxis = normalize(vec3(worldNormal.z, 0.25, -worldNormal.x));
+    vec3 shearOffset = shearAxis * combatStress * 0.0038;
+
+    batteredPos = worldPos + dentOffset + shearOffset;
+    offsetPos = batteredPos + worldNormal * 0.0062;
+  }
 
   mat4 invModel = inverse(u_model);
   vec4 localBattered = invModel * vec4(batteredPos, 1.0);
@@ -85,16 +96,9 @@ void main() {
   v_tangent = t;
   v_bitangent = b;
 
+  // Keep armor/material selection stable: 1.0 only for armor material.
   float height = offsetPos.y;
-
-  // Armor layer detection - segmentata ONLY on torso
-  if (height > 1.50) {
-    v_armorLayer = 0.0; // Heavy steel galea helmet
-  } else if (height > 0.90 && height <= 1.50) {
-    v_armorLayer = 1.0; // Heavy lorica segmentata - TORSO ONLY
-  } else {
-    v_armorLayer = 2.0; // Leather pteruges
-  }
+  v_armorLayer = (u_materialId == 1) ? 1.0 : 0.0;
 
   // Body height normalization
   float torsoMin = 0.55;

+ 2 - 0
game/CMakeLists.txt

@@ -73,6 +73,8 @@ add_library(game_systems STATIC
     units/horse_spearman.cpp
     units/spearman.cpp
     units/healer.cpp
+    units/catapult.cpp
+    units/ballista.cpp
     units/troop_catalog.cpp
     units/troop_catalog_loader.cpp
     units/factory.cpp

+ 1 - 1
game/core/component.h

@@ -82,7 +82,7 @@ public:
   Game::Units::SpawnType spawn_type{Game::Units::SpawnType::Archer};
   int owner_id{0};
   float vision_range;
-  Game::Systems::NationID nation_id{Game::Systems::NationID::KingdomOfIron};
+  Game::Systems::NationID nation_id{Game::Systems::NationID::RomanRepublic};
 };
 
 class MovementComponent : public Component {

+ 1 - 1
game/core/serialization.cpp

@@ -313,7 +313,7 @@ void Serialization::deserializeEntity(Entity *entity, const QJsonObject &json) {
       } else {
         qWarning() << "Unknown nation ID in save file:" << nation_str
                    << "- using default";
-        unit->nation_id = Game::Systems::NationID::KingdomOfIron;
+        unit->nation_id = Game::Systems::NationID::RomanRepublic;
       }
     }
   }

+ 2 - 8
game/systems/nation_id.h

@@ -8,19 +8,17 @@
 
 namespace Game::Systems {
 
-enum class NationID : std::uint8_t { KingdomOfIron, RomanRepublic, Carthage };
+enum class NationID : std::uint8_t { RomanRepublic, Carthage };
 
 inline auto nationIDToQString(NationID id) -> QString {
   switch (id) {
-  case NationID::KingdomOfIron:
-    return QStringLiteral("kingdom_of_iron");
   case NationID::RomanRepublic:
     return QStringLiteral("roman_republic");
   case NationID::Carthage:
     return QStringLiteral("carthage");
   }
 
-  return QStringLiteral("kingdom_of_iron");
+  return QStringLiteral("roman_republic");
 }
 
 inline auto nationIDToString(NationID id) -> std::string {
@@ -29,10 +27,6 @@ inline auto nationIDToString(NationID id) -> std::string {
 
 inline auto tryParseNationID(const QString &value, NationID &out) -> bool {
   const QString lowered = value.trimmed().toLower();
-  if (lowered == QStringLiteral("kingdom_of_iron")) {
-    out = NationID::KingdomOfIron;
-    return true;
-  }
   if (lowered == QStringLiteral("roman_republic")) {
     out = NationID::RomanRepublic;
     return true;

+ 9 - 12
game/systems/nation_registry.cpp

@@ -137,13 +137,13 @@ void NationRegistry::initializeDefaults() {
 
   auto nations = NationLoader::load_default_nations();
   if (nations.empty()) {
-    Nation kingdom_of_iron;
-    kingdom_of_iron.id = NationID::KingdomOfIron;
-    kingdom_of_iron.displayName = "Kingdom of Iron";
-    kingdom_of_iron.primaryBuilding = Game::Units::BuildingType::Barracks;
-    kingdom_of_iron.formation_type = FormationType::Roman;
+    Nation roman;
+    roman.id = NationID::RomanRepublic;
+    roman.displayName = "Roman Republic";
+    roman.primaryBuilding = Game::Units::BuildingType::Barracks;
+    roman.formation_type = FormationType::Roman;
 
-    auto appendTroop = [&kingdom_of_iron](Game::Units::TroopType type) {
+    auto appendTroop = [&roman](Game::Units::TroopType type) {
       TroopType troop_entry;
       troop_entry.unit_type = type;
 
@@ -155,7 +155,7 @@ void NationRegistry::initializeDefaults() {
       troop_entry.buildTime = troop_class.production.build_time;
       troop_entry.priority = troop_class.production.priority;
 
-      kingdom_of_iron.availableTroops.push_back(std::move(troop_entry));
+      roman.availableTroops.push_back(std::move(troop_entry));
     };
 
     appendTroop(Game::Units::TroopType::Archer);
@@ -163,14 +163,11 @@ void NationRegistry::initializeDefaults() {
     appendTroop(Game::Units::TroopType::Spearman);
     appendTroop(Game::Units::TroopType::MountedKnight);
 
-    registerNation(std::move(kingdom_of_iron));
-    m_defaultNation = NationID::KingdomOfIron;
+    registerNation(std::move(roman));
+    m_defaultNation = NationID::RomanRepublic;
   } else {
     NationID fallback_default = nations.front().id;
     for (auto &nation : nations) {
-      if (nation.id == NationID::KingdomOfIron) {
-        fallback_default = nation.id;
-      }
       registerNation(std::move(nation));
     }
     m_defaultNation = fallback_default;

+ 1 - 1
game/systems/nation_registry.h

@@ -100,7 +100,7 @@ private:
   std::vector<Nation> m_nations;
   std::unordered_map<NationID, size_t> m_nationIndex;
   std::unordered_map<int, NationID> m_playerNations;
-  NationID m_defaultNation = NationID::KingdomOfIron;
+  NationID m_defaultNation = NationID::RomanRepublic;
   bool m_initialized = false;
 };
 

+ 1 - 1
game/systems/production_service.h

@@ -24,7 +24,7 @@ enum class ProductionResult {
 struct ProductionState {
   bool has_barracks = false;
   bool inProgress = false;
-  NationID nation_id = NationID::KingdomOfIron;
+  NationID nation_id = NationID::RomanRepublic;
   Game::Units::TroopType product_type = Game::Units::TroopType::Archer;
   float timeRemaining = 0.0F;
   float buildTime = 0.0F;

+ 103 - 0
game/units/ballista.cpp

@@ -0,0 +1,103 @@
+#include "ballista.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 {
+
+Ballista::Ballista(Engine::Core::World &world)
+    : Unit(world, TroopType::Ballista) {}
+
+auto Ballista::Create(Engine::Core::World &world,
+                      const SpawnParams &params) -> std::unique_ptr<Ballista> {
+  auto unit = std::unique_ptr<Ballista>(new Ballista(world));
+  unit->init(params);
+  return unit;
+}
+
+void Ballista::init(const SpawnParams &params) {
+
+  auto *e = m_world->createEntity();
+  m_id = e->getId();
+
+  const auto nation_id = resolve_nation_id(params);
+  auto profile = Game::Systems::TroopProfileService::instance().get_profile(
+      nation_id, TroopType::Ballista);
+
+  m_t = e->addComponent<Engine::Core::TransformComponent>();
+  m_t->position = {params.position.x(), params.position.y(),
+                   params.position.z()};
+  float const scale = profile.visuals.render_scale;
+  m_t->scale = {scale, scale, scale};
+
+  m_r = e->addComponent<Engine::Core::RenderableComponent>("", "");
+  m_r->visible = true;
+  m_r->rendererId = profile.visuals.renderer_id;
+
+  m_u = e->addComponent<Engine::Core::UnitComponent>();
+  m_u->spawn_type = params.spawn_type;
+  m_u->health = profile.combat.health;
+  m_u->max_health = profile.combat.max_health;
+  m_u->speed = profile.combat.speed;
+  m_u->owner_id = params.player_id;
+  m_u->vision_range = profile.combat.vision_range;
+  m_u->nation_id = nation_id;
+
+  if (params.aiControlled) {
+    e->addComponent<Engine::Core::AIControlledComponent>();
+  }
+
+  QVector3D const tc = team_color(m_u->owner_id);
+  m_r->color[0] = tc.x();
+  m_r->color[1] = tc.y();
+  m_r->color[2] = tc.z();
+
+  m_mv = e->addComponent<Engine::Core::MovementComponent>();
+  if (m_mv != nullptr) {
+    m_mv->goalX = params.position.x();
+    m_mv->goalY = params.position.z();
+    m_mv->target_x = params.position.x();
+    m_mv->target_y = params.position.z();
+  }
+
+  m_atk = e->addComponent<Engine::Core::AttackComponent>();
+
+  m_atk->range = profile.combat.ranged_range;
+  m_atk->damage = profile.combat.ranged_damage;
+  m_atk->cooldown = profile.combat.ranged_cooldown;
+
+  m_atk->meleeRange = profile.combat.melee_range;
+  m_atk->meleeDamage = profile.combat.melee_damage;
+  m_atk->meleeCooldown = profile.combat.melee_cooldown;
+
+  m_atk->preferredMode = profile.combat.can_ranged
+                             ? Engine::Core::AttackComponent::CombatMode::Ranged
+                             : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->currentMode = profile.combat.can_ranged
+                           ? Engine::Core::AttackComponent::CombatMode::Ranged
+                           : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->canRanged = profile.combat.can_ranged;
+  m_atk->canMelee = profile.combat.can_melee;
+}
+
+} // namespace Game::Units

+ 22 - 0
game/units/ballista.h

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

+ 107 - 0
game/units/catapult.cpp

@@ -0,0 +1,107 @@
+#include "catapult.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 {
+
+Catapult::Catapult(Engine::Core::World &world)
+    : Unit(world, TroopType::Catapult) {}
+
+auto Catapult::Create(Engine::Core::World &world,
+                      const SpawnParams &params) -> std::unique_ptr<Catapult> {
+  auto unit = std::unique_ptr<Catapult>(new Catapult(world));
+  unit->init(params);
+  return unit;
+}
+
+void Catapult::init(const SpawnParams &params) {
+
+  auto *e = m_world->createEntity();
+  m_id = e->getId();
+
+  const auto nation_id = resolve_nation_id(params);
+  auto profile = Game::Systems::TroopProfileService::instance().get_profile(
+      nation_id, TroopType::Catapult);
+
+  m_t = e->addComponent<Engine::Core::TransformComponent>();
+  m_t->position = {params.position.x(), params.position.y(),
+                   params.position.z()};
+  float const scale = profile.visuals.render_scale;
+  m_t->scale = {scale, scale, scale};
+
+  m_r = e->addComponent<Engine::Core::RenderableComponent>("", "");
+  m_r->visible = true;
+  m_r->rendererId = profile.visuals.renderer_id;
+
+  m_u = e->addComponent<Engine::Core::UnitComponent>();
+  m_u->spawn_type = params.spawn_type;
+  m_u->health = profile.combat.health;
+  m_u->max_health = profile.combat.max_health;
+  m_u->speed = profile.combat.speed;
+  m_u->owner_id = params.player_id;
+  m_u->vision_range = profile.combat.vision_range;
+  m_u->nation_id = nation_id;
+
+  if (params.aiControlled) {
+    e->addComponent<Engine::Core::AIControlledComponent>();
+  }
+
+  QVector3D const tc = team_color(m_u->owner_id);
+  m_r->color[0] = tc.x();
+  m_r->color[1] = tc.y();
+  m_r->color[2] = tc.z();
+
+  m_mv = e->addComponent<Engine::Core::MovementComponent>();
+  if (m_mv != nullptr) {
+    m_mv->goalX = params.position.x();
+    m_mv->goalY = params.position.z();
+    m_mv->target_x = params.position.x();
+    m_mv->target_y = params.position.z();
+  }
+
+  m_atk = e->addComponent<Engine::Core::AttackComponent>();
+
+  m_atk->range = profile.combat.ranged_range;
+  m_atk->damage = profile.combat.ranged_damage;
+  m_atk->cooldown = profile.combat.ranged_cooldown;
+
+  m_atk->meleeRange = profile.combat.melee_range;
+  m_atk->meleeDamage = profile.combat.melee_damage;
+  m_atk->meleeCooldown = profile.combat.melee_cooldown;
+
+  m_atk->preferredMode = profile.combat.can_ranged
+                             ? Engine::Core::AttackComponent::CombatMode::Ranged
+                             : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->currentMode = profile.combat.can_ranged
+                           ? Engine::Core::AttackComponent::CombatMode::Ranged
+                           : Engine::Core::AttackComponent::CombatMode::Melee;
+  m_atk->canRanged = profile.combat.can_ranged;
+  m_atk->canMelee = profile.combat.can_melee;
+  m_atk->max_heightDifference = 5.0F;
+
+  Engine::Core::EventManager::instance().publish(
+      Engine::Core::UnitSpawnedEvent(m_id, m_u->owner_id, m_u->spawn_type));
+}
+
+} // namespace Game::Units

+ 17 - 0
game/units/catapult.h

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

+ 12 - 0
game/units/factory.cpp

@@ -1,6 +1,8 @@
 #include "factory.h"
 #include "archer.h"
+#include "ballista.h"
 #include "barracks.h"
+#include "catapult.h"
 #include "healer.h"
 #include "horse_archer.h"
 #include "horse_spearman.h"
@@ -48,6 +50,16 @@ void registerBuiltInUnits(UnitFactoryRegistry &reg) {
     return Healer::Create(world, params);
   });
 
+  reg.registerFactory(SpawnType::Catapult, [](Engine::Core::World &world,
+                                              const SpawnParams &params) {
+    return Catapult::Create(world, params);
+  });
+
+  reg.registerFactory(SpawnType::Ballista, [](Engine::Core::World &world,
+                                              const SpawnParams &params) {
+    return Ballista::Create(world, params);
+  });
+
   reg.registerFactory(SpawnType::Barracks, [](Engine::Core::World &world,
                                               const SpawnParams &params) {
     return Barracks::Create(world, params);

+ 28 - 0
game/units/spawn_type.h

@@ -17,6 +17,8 @@ enum class SpawnType : std::uint8_t {
   HorseArcher,
   HorseSpearman,
   Healer,
+  Catapult,
+  Ballista,
   Barracks
 };
 
@@ -36,6 +38,10 @@ inline auto spawn_typeToQString(SpawnType type) -> QString {
     return QStringLiteral("horse_spearman");
   case SpawnType::Healer:
     return QStringLiteral("healer");
+  case SpawnType::Catapult:
+    return QStringLiteral("catapult");
+  case SpawnType::Ballista:
+    return QStringLiteral("ballista");
   case SpawnType::Barracks:
     return QStringLiteral("barracks");
   }
@@ -77,6 +83,14 @@ inline auto tryParseSpawnType(const QString &value, SpawnType &out) -> bool {
     out = SpawnType::Healer;
     return true;
   }
+  if (lowered == QStringLiteral("catapult")) {
+    out = SpawnType::Catapult;
+    return true;
+  }
+  if (lowered == QStringLiteral("ballista")) {
+    out = SpawnType::Ballista;
+    return true;
+  }
   if (lowered == QStringLiteral("barracks")) {
     out = SpawnType::Barracks;
     return true;
@@ -107,6 +121,12 @@ spawn_typeFromString(const std::string &str) -> std::optional<SpawnType> {
   if (str == "healer") {
     return SpawnType::Healer;
   }
+  if (str == "catapult") {
+    return SpawnType::Catapult;
+  }
+  if (str == "ballista") {
+    return SpawnType::Ballista;
+  }
   if (str == "barracks") {
     return SpawnType::Barracks;
   }
@@ -137,6 +157,10 @@ inline auto spawn_typeToTroopType(SpawnType type) -> std::optional<TroopType> {
     return TroopType::HorseSpearman;
   case SpawnType::Healer:
     return TroopType::Healer;
+  case SpawnType::Catapult:
+    return TroopType::Catapult;
+  case SpawnType::Ballista:
+    return TroopType::Ballista;
   case SpawnType::Barracks:
     return std::nullopt;
   }
@@ -159,6 +183,10 @@ inline auto spawn_typeFromTroopType(TroopType type) -> SpawnType {
     return SpawnType::HorseSpearman;
   case TroopType::Healer:
     return SpawnType::Healer;
+  case TroopType::Catapult:
+    return SpawnType::Catapult;
+  case TroopType::Ballista:
+    return SpawnType::Ballista;
   }
   return SpawnType::Archer;
 }

+ 7 - 7
game/units/troop_catalog.cpp

@@ -64,7 +64,7 @@ void TroopCatalog::register_defaults() {
   archer.visuals.selection_ring_size = 1.2F;
   archer.visuals.selection_ring_ground_offset = 0.0F;
   archer.visuals.selection_ring_y_offset = 0.0F;
-  archer.visuals.renderer_id = "troops/kingdom/archer";
+  archer.visuals.renderer_id = "troops/roman/archer";
 
   archer.individuals_per_unit = 20;
   archer.max_units_per_row = 5;
@@ -96,7 +96,7 @@ void TroopCatalog::register_defaults() {
   swordsman.visuals.selection_ring_size = 1.1F;
   swordsman.visuals.selection_ring_ground_offset = 0.0F;
   swordsman.visuals.selection_ring_y_offset = 0.0F;
-  swordsman.visuals.renderer_id = "troops/kingdom/swordsman";
+  swordsman.visuals.renderer_id = "troops/roman/swordsman";
 
   swordsman.individuals_per_unit = 15;
   swordsman.max_units_per_row = 5;
@@ -128,7 +128,7 @@ void TroopCatalog::register_defaults() {
   spearman.visuals.selection_ring_size = 1.4F;
   spearman.visuals.selection_ring_ground_offset = 0.0F;
   spearman.visuals.selection_ring_y_offset = 0.0F;
-  spearman.visuals.renderer_id = "troops/kingdom/spearman";
+  spearman.visuals.renderer_id = "troops/roman/spearman";
 
   spearman.individuals_per_unit = 24;
   spearman.max_units_per_row = 6;
@@ -160,7 +160,7 @@ void TroopCatalog::register_defaults() {
   horse_swordsman.visuals.selection_ring_size = 2.0F;
   horse_swordsman.visuals.selection_ring_ground_offset = 0.0F;
   horse_swordsman.visuals.selection_ring_y_offset = 0.0F;
-  horse_swordsman.visuals.renderer_id = "troops/kingdom/horse_swordsman";
+  horse_swordsman.visuals.renderer_id = "troops/roman/horse_swordsman";
 
   horse_swordsman.individuals_per_unit = 9;
   horse_swordsman.max_units_per_row = 3;
@@ -190,7 +190,7 @@ void TroopCatalog::register_defaults() {
   horse_archer.visuals.selection_ring_size = 2.0F;
   horse_archer.visuals.selection_ring_ground_offset = 0.0F;
   horse_archer.visuals.selection_ring_y_offset = 0.0F;
-  horse_archer.visuals.renderer_id = "troops/kingdom/horse_archer";
+  horse_archer.visuals.renderer_id = "troops/roman/horse_archer";
 
   horse_archer.individuals_per_unit = 8;
   horse_archer.max_units_per_row = 3;
@@ -224,7 +224,7 @@ void TroopCatalog::register_defaults() {
   healer.visuals.selection_ring_size = 1.2F;
   healer.visuals.selection_ring_ground_offset = 0.0F;
   healer.visuals.selection_ring_y_offset = 0.0F;
-  healer.visuals.renderer_id = "troops/kingdom/healer";
+  healer.visuals.renderer_id = "troops/roman/healer";
 
   healer.individuals_per_unit = 1;
   healer.max_units_per_row = 1;
@@ -256,7 +256,7 @@ void TroopCatalog::register_defaults() {
   horse_spearman.visuals.selection_ring_size = 2.0F;
   horse_spearman.visuals.selection_ring_ground_offset = 0.0F;
   horse_spearman.visuals.selection_ring_y_offset = 0.0F;
-  horse_spearman.visuals.renderer_id = "troops/kingdom/horse_spearman";
+  horse_spearman.visuals.renderer_id = "troops/roman/horse_spearman";
 
   horse_spearman.individuals_per_unit = 8;
   horse_spearman.max_units_per_row = 3;

+ 15 - 1
game/units/troop_type.h

@@ -16,7 +16,9 @@ enum class TroopType {
   MountedKnight,
   HorseArcher,
   HorseSpearman,
-  Healer
+  Healer,
+  Catapult,
+  Ballista
 };
 
 inline auto troop_typeToQString(TroopType type) -> QString {
@@ -35,6 +37,10 @@ inline auto troop_typeToQString(TroopType type) -> QString {
     return QStringLiteral("horse_spearman");
   case TroopType::Healer:
     return QStringLiteral("healer");
+  case TroopType::Catapult:
+    return QStringLiteral("catapult");
+  case TroopType::Ballista:
+    return QStringLiteral("ballista");
   }
   return QStringLiteral("archer");
 }
@@ -77,6 +83,14 @@ inline auto tryParseTroopType(const QString &value, TroopType &out) -> bool {
     out = TroopType::Healer;
     return true;
   }
+  if (lowered == QStringLiteral("catapult")) {
+    out = TroopType::Catapult;
+    return true;
+  }
+  if (lowered == QStringLiteral("ballista")) {
+    out = TroopType::Ballista;
+    return true;
+  }
   return false;
 }
 

+ 1 - 1
game/units/unit.h

@@ -28,7 +28,7 @@ struct SpawnParams {
   SpawnType spawn_type = SpawnType::Archer;
   bool aiControlled = false;
   int maxPopulation = 100;
-  Game::Systems::NationID nation_id = Game::Systems::NationID::KingdomOfIron;
+  Game::Systems::NationID nation_id = Game::Systems::NationID::RomanRepublic;
 };
 
 class Unit {

+ 6 - 17
render/CMakeLists.txt

@@ -32,21 +32,15 @@ add_library(render_gl STATIC
     ground/pine_renderer.cpp
     ground/firecamp_renderer.cpp
     entity/registry.cpp
-    entity/nations/kingdom/archer_renderer.cpp
     entity/nations/roman/archer_renderer.cpp
     entity/nations/carthage/archer_renderer.cpp
-    entity/nations/kingdom/archer_style.cpp
     entity/nations/roman/archer_style.cpp
     entity/nations/carthage/archer_style.cpp
-    entity/nations/kingdom/spearman_style.cpp
     entity/nations/roman/swordsman_renderer.cpp
-    entity/nations/kingdom/swordsman_renderer.cpp
     entity/nations/carthage/swordsman_renderer.cpp
     entity/nations/roman/swordsman_style.cpp
     entity/nations/carthage/swordsman_style.cpp
-    entity/nations/kingdom/swordsman_style.cpp
     entity/nations/roman/spearman_renderer.cpp
-    entity/nations/kingdom/spearman_renderer.cpp
     entity/nations/carthage/spearman_renderer.cpp
     entity/nations/roman/spearman_style.cpp
     entity/nations/carthage/spearman_style.cpp
@@ -59,23 +53,23 @@ add_library(render_gl STATIC
     entity/horse_spearman_renderer_base.cpp
     entity/nations/roman/horse_swordsman_renderer.cpp
     entity/nations/carthage/horse_swordsman_renderer.cpp
-    entity/nations/kingdom/horse_swordsman_renderer.cpp
     entity/nations/roman/horse_archer_renderer.cpp
     entity/nations/carthage/horse_archer_renderer.cpp
-    entity/nations/kingdom/horse_archer_renderer.cpp
     entity/nations/roman/horse_spearman_renderer.cpp
     entity/nations/carthage/horse_spearman_renderer.cpp
-    entity/nations/kingdom/horse_spearman_renderer.cpp
-    entity/nations/kingdom/healer_renderer.cpp
-    entity/nations/kingdom/healer_style.cpp
     entity/nations/roman/healer_renderer.cpp
     entity/nations/roman/healer_style.cpp
     entity/nations/carthage/healer_renderer.cpp
     entity/nations/carthage/healer_style.cpp
     entity/barracks_renderer.cpp
-    entity/nations/kingdom/barracks_renderer.cpp
     entity/nations/roman/barracks_renderer.cpp
     entity/nations/carthage/barracks_renderer.cpp
+    entity/catapult_renderer.cpp
+    entity/nations/roman/catapult_renderer.cpp
+    entity/nations/carthage/catapult_renderer.cpp
+    entity/ballista_renderer.cpp
+    entity/nations/roman/ballista_renderer.cpp
+    entity/nations/carthage/ballista_renderer.cpp
     # entity/arrow.cpp removed; arrow VFX renderer code moved to geom/arrow.cpp
     geom/selection_ring.cpp
     geom/selection_disc.cpp
@@ -94,7 +88,6 @@ add_library(render_gl STATIC
     equipment/equipment_registry.cpp
     equipment/register_equipment.cpp
     equipment/armor/tunic_renderer.cpp
-    equipment/armor/kingdom_armor.cpp
     equipment/armor/roman_armor.cpp
     equipment/armor/armor_light_carthage.cpp
     equipment/armor/armor_heavy_carthage.cpp
@@ -107,17 +100,13 @@ add_library(render_gl STATIC
     equipment/weapons/sword_renderer.cpp
     equipment/weapons/sword_carthage.cpp
     equipment/weapons/sword_roman.cpp
-    equipment/weapons/sword_kingdom.cpp
     equipment/weapons/spear_renderer.cpp
     equipment/weapons/shield_renderer.cpp
     equipment/weapons/shield_carthage.cpp
     equipment/weapons/shield_roman.cpp
-    equipment/weapons/shield_kingdom.cpp
     equipment/helmets/headwrap.cpp
     equipment/helmets/roman_heavy_helmet.cpp
     equipment/helmets/roman_light_helmet.cpp
-    equipment/helmets/kingdom_heavy_helmet.cpp
-    equipment/helmets/kingdom_light_helmet.cpp
     equipment/helmets/carthage_heavy_helmet.cpp
     equipment/helmets/carthage_light_helmet.cpp
     equipment/armor/chainmail_armor.cpp

+ 46 - 0
render/entity/ballista_renderer.cpp

@@ -0,0 +1,46 @@
+#include "ballista_renderer.h"
+#include "../../game/core/component.h"
+#include "../../game/systems/nation_id.h"
+#include "nations/carthage/ballista_renderer.h"
+#include "nations/roman/ballista_renderer.h"
+#include "registry.h"
+
+namespace Render::GL {
+
+void register_ballista_renderer(EntityRendererRegistry &registry) {
+
+  Roman::register_ballista_renderer(registry);
+  Carthage::register_ballista_renderer(registry);
+
+  registry.register_renderer(
+      "ballista", [&registry](const DrawContext &p, ISubmitter &out) {
+        if (p.entity == nullptr) {
+          return;
+        }
+
+        auto *unit = p.entity->getComponent<Engine::Core::UnitComponent>();
+        if (unit == nullptr) {
+          return;
+        }
+
+        std::string renderer_key;
+        switch (unit->nation_id) {
+        case Game::Systems::NationID::Carthage:
+          renderer_key = "troops/carthage/ballista";
+          break;
+        case Game::Systems::NationID::RomanRepublic:
+          renderer_key = "troops/roman/ballista";
+          break;
+        default:
+          renderer_key = "troops/roman/ballista";
+          break;
+        }
+
+        auto renderer = registry.get(renderer_key);
+        if (renderer) {
+          renderer(p, out);
+        }
+      });
+}
+
+} // namespace Render::GL

+ 9 - 0
render/entity/ballista_renderer.h

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

+ 1 - 4
render/entity/barracks_renderer.cpp

@@ -2,7 +2,6 @@
 #include "../../game/core/component.h"
 #include "../../game/systems/nation_id.h"
 #include "nations/carthage/barracks_renderer.h"
-#include "nations/kingdom/barracks_renderer.h"
 #include "nations/roman/barracks_renderer.h"
 #include "registry.h"
 
@@ -10,7 +9,6 @@ namespace Render::GL {
 
 void register_barracks_renderer(EntityRendererRegistry &registry) {
 
-  Kingdom::register_barracks_renderer(registry);
   Roman::register_barracks_renderer(registry);
   Carthage::register_barracks_renderer(registry);
 
@@ -33,9 +31,8 @@ void register_barracks_renderer(EntityRendererRegistry &registry) {
         case Game::Systems::NationID::RomanRepublic:
           renderer_key = "barracks_roman";
           break;
-        case Game::Systems::NationID::KingdomOfIron:
         default:
-          renderer_key = "barracks_kingdom";
+          renderer_key = "barracks_roman";
           break;
         }
 

+ 46 - 0
render/entity/catapult_renderer.cpp

@@ -0,0 +1,46 @@
+#include "catapult_renderer.h"
+#include "../../game/core/component.h"
+#include "../../game/systems/nation_id.h"
+#include "nations/carthage/catapult_renderer.h"
+#include "nations/roman/catapult_renderer.h"
+#include "registry.h"
+
+namespace Render::GL {
+
+void register_catapult_renderer(EntityRendererRegistry &registry) {
+
+  Roman::register_catapult_renderer(registry);
+  Carthage::register_catapult_renderer(registry);
+
+  registry.register_renderer(
+      "catapult", [&registry](const DrawContext &p, ISubmitter &out) {
+        if (p.entity == nullptr) {
+          return;
+        }
+
+        auto *unit = p.entity->getComponent<Engine::Core::UnitComponent>();
+        if (unit == nullptr) {
+          return;
+        }
+
+        std::string renderer_key;
+        switch (unit->nation_id) {
+        case Game::Systems::NationID::Carthage:
+          renderer_key = "troops/carthage/catapult";
+          break;
+        case Game::Systems::NationID::RomanRepublic:
+          renderer_key = "troops/roman/catapult";
+          break;
+        default:
+          renderer_key = "troops/roman/catapult";
+          break;
+        }
+
+        auto renderer = registry.get(renderer_key);
+        if (renderer) {
+          renderer(p, out);
+        }
+      });
+}
+
+} // namespace Render::GL

+ 9 - 0
render/entity/catapult_renderer.h

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

+ 2 - 2
render/entity/horse_spearman_renderer_base.cpp

@@ -49,7 +49,7 @@ HorseSpearmanRendererBase::HorseSpearmanRendererBase(
 
 auto HorseSpearmanRendererBase::get_proportion_scaling() const -> QVector3D {
 
-  return QVector3D{0.88F, 0.86F, 0.90F};
+  return QVector3D{0.84F, 0.84F, 0.86F};
 }
 
 auto HorseSpearmanRendererBase::get_mount_scale() const -> float {
@@ -59,7 +59,7 @@ auto HorseSpearmanRendererBase::get_mount_scale() const -> float {
 void HorseSpearmanRendererBase::adjust_variation(
     const DrawContext &, uint32_t, VariationParams &variation) const {
   variation.height_scale = 0.90F;
-  variation.bulk_scale = 0.78F;
+  variation.bulk_scale = 0.74F;
   variation.stance_width = 0.60F;
   variation.arm_swing_amp = 0.40F;
   variation.walk_speed_mult = 1.0F;

+ 294 - 0
render/entity/nations/carthage/ballista_renderer.cpp

@@ -0,0 +1,294 @@
+#include "ballista_renderer.h"
+#include "../../../../game/core/component.h"
+#include "../../../../game/visuals/team_colors.h"
+#include "../../../geom/math_utils.h"
+#include "../../../geom/transforms.h"
+#include "../../../gl/primitives.h"
+#include "../../../gl/resources.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../registry.h"
+
+#include <QMatrix4x4>
+#include <QVector3D>
+#include <cmath>
+
+namespace Render::GL::Carthage {
+namespace {
+
+using Render::Geom::clamp01;
+using Render::Geom::clampVec01;
+using Render::Geom::cylinderBetween;
+
+struct CarthageBallistaPalette {
+  QVector3D wood_frame{0.50F, 0.35F, 0.20F};
+  QVector3D wood_dark{0.35F, 0.25F, 0.15F};
+  QVector3D wood_light{0.60F, 0.45F, 0.28F};
+  QVector3D metal_iron{0.35F, 0.33F, 0.32F};
+  QVector3D metal_bronze{0.75F, 0.55F, 0.28F};
+  QVector3D metal_gold{0.85F, 0.70F, 0.30F};
+  QVector3D rope{0.58F, 0.52F, 0.40F};
+  QVector3D leather{0.45F, 0.32F, 0.22F};
+  QVector3D purple_accent{0.45F, 0.20F, 0.50F};
+  QVector3D team{0.8F, 0.9F, 1.0F};
+};
+
+inline auto make_palette(const QVector3D &team) -> CarthageBallistaPalette {
+  CarthageBallistaPalette p;
+  p.team = clampVec01(team);
+  return p;
+}
+
+inline void draw_box(ISubmitter &out, Mesh *unit, Texture *white,
+                     const QMatrix4x4 &model, const QVector3D &pos,
+                     const QVector3D &size, const QVector3D &color) {
+  QMatrix4x4 m = model;
+  m.translate(pos);
+  m.scale(size);
+  out.mesh(unit, m, color, white, 1.0F);
+}
+
+inline void draw_cyl(ISubmitter &out, const QMatrix4x4 &model,
+                     const QVector3D &a, const QVector3D &b, float r,
+                     const QVector3D &color, Texture *white) {
+  out.mesh(getUnitCylinder(), model * cylinderBetween(a, b, r), color, white,
+           1.0F);
+}
+
+void drawBaseFrame(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                   Texture *white, const CarthageBallistaPalette &c) {
+
+  draw_box(out, unit, white, p.model, QVector3D(-0.38F, 0.18F, 0.0F),
+           QVector3D(0.06F, 0.12F, 0.28F), c.wood_frame);
+  draw_box(out, unit, white, p.model, QVector3D(0.38F, 0.18F, 0.0F),
+           QVector3D(0.06F, 0.12F, 0.28F), c.wood_frame);
+
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.25F, -0.26F),
+           QVector3D(0.43F, 0.08F, 0.06F), c.wood_dark);
+
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.15F, 0.23F),
+           QVector3D(0.43F, 0.06F, 0.06F), c.wood_frame);
+
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.11F, -0.30F),
+           QVector3D(0.48F, 0.01F, 0.02F), c.metal_bronze);
+}
+
+void drawWheels(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                Texture *white, const CarthageBallistaPalette &c) {
+
+  float wheel_radius = 0.13F;
+  float wheel_thickness = 0.032F;
+
+  QVector3D left_pos(-0.40F, wheel_radius, 0.0F);
+  QVector3D right_pos(0.40F, wheel_radius, 0.0F);
+
+  auto drawWheel = [&](const QVector3D &pos, float side_offset) {
+    QVector3D inner = pos + QVector3D(side_offset * wheel_thickness, 0, 0);
+    QVector3D outer =
+        pos + QVector3D(side_offset * (wheel_thickness + 0.045F), 0, 0);
+
+    draw_cyl(out, p.model, inner, outer, wheel_radius, c.wood_dark, white);
+
+    draw_cyl(out, p.model, inner - QVector3D(side_offset * 0.004F, 0, 0),
+             outer + QVector3D(side_offset * 0.004F, 0, 0),
+             wheel_radius + 0.010F, c.metal_bronze, white);
+
+    draw_cyl(out, p.model, inner - QVector3D(side_offset * 0.012F, 0, 0),
+             outer + QVector3D(side_offset * 0.012F, 0, 0), 0.032F,
+             c.metal_gold, white);
+
+    for (int s = 0; s < 8; ++s) {
+      float angle = s * 3.14159F / 4.0F;
+      float spoke_y = std::sin(angle) * wheel_radius * 0.7F;
+      float spoke_z = std::cos(angle) * wheel_radius * 0.7F;
+      QVector3D spoke_pos =
+          pos +
+          QVector3D(side_offset * (wheel_thickness + 0.022F), spoke_y, spoke_z);
+      draw_cyl(out, p.model,
+               pos + QVector3D(side_offset * (wheel_thickness + 0.022F), 0, 0),
+               spoke_pos, 0.010F, c.wood_frame, white);
+    }
+  };
+
+  drawWheel(left_pos, -1.0F);
+  drawWheel(right_pos, 1.0F);
+
+  draw_cyl(out, p.model, QVector3D(-0.36F, wheel_radius, 0.0F),
+           QVector3D(0.36F, wheel_radius, 0.0F), 0.020F, c.metal_bronze, white);
+}
+
+void drawTorsionBundles(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                        Texture *white, const CarthageBallistaPalette &c) {
+
+  QMatrix4x4 tilted = p.model;
+  tilted.rotate(30.0F, 1.0F, 0.0F, 0.0F);
+
+  draw_cyl(out, tilted, QVector3D(-0.23F, 0.20F, -0.26F),
+           QVector3D(-0.23F, 0.34F, -0.26F), 0.075F, c.rope, white);
+
+  draw_cyl(out, tilted, QVector3D(0.23F, 0.20F, -0.26F),
+           QVector3D(0.23F, 0.34F, -0.26F), 0.075F, c.rope, white);
+
+  draw_cyl(out, tilted, QVector3D(-0.23F, 0.34F, -0.26F),
+           QVector3D(-0.23F, 0.36F, -0.26F), 0.085F, c.metal_bronze, white);
+  draw_cyl(out, tilted, QVector3D(0.23F, 0.34F, -0.26F),
+           QVector3D(0.23F, 0.36F, -0.26F), 0.085F, c.metal_bronze, white);
+  draw_cyl(out, tilted, QVector3D(-0.23F, 0.18F, -0.26F),
+           QVector3D(-0.23F, 0.20F, -0.26F), 0.085F, c.metal_bronze, white);
+  draw_cyl(out, tilted, QVector3D(0.23F, 0.18F, -0.26F),
+           QVector3D(0.23F, 0.20F, -0.26F), 0.085F, c.metal_bronze, white);
+
+  draw_cyl(out, tilted, QVector3D(-0.23F, 0.27F, -0.26F),
+           QVector3D(-0.23F, 0.28F, -0.26F), 0.078F, c.metal_gold, white);
+  draw_cyl(out, tilted, QVector3D(0.23F, 0.27F, -0.26F),
+           QVector3D(0.23F, 0.28F, -0.26F), 0.078F, c.metal_gold, white);
+}
+
+void drawArms(const DrawContext &p, ISubmitter &out, Mesh *unit, Texture *white,
+              const CarthageBallistaPalette &c) {
+
+  QMatrix4x4 tilted = p.model;
+  tilted.rotate(30.0F, 1.0F, 0.0F, 0.0F);
+
+  draw_cyl(out, tilted, QVector3D(-0.23F, 0.27F, -0.26F),
+           QVector3D(-0.43F, 0.31F, -0.08F), 0.023F, c.wood_frame, white);
+
+  draw_cyl(out, tilted, QVector3D(0.23F, 0.27F, -0.26F),
+           QVector3D(0.43F, 0.31F, -0.08F), 0.023F, c.wood_frame, white);
+
+  QMatrix4x4 left_socket = tilted;
+  left_socket.translate(QVector3D(-0.43F, 0.31F, -0.08F));
+  left_socket.scale(0.022F);
+  out.mesh(getUnitSphere(), left_socket, c.metal_bronze, white, 1.0F);
+
+  QMatrix4x4 right_socket = tilted;
+  right_socket.translate(QVector3D(0.43F, 0.31F, -0.08F));
+  right_socket.scale(0.022F);
+  out.mesh(getUnitSphere(), right_socket, c.metal_bronze, white, 1.0F);
+}
+
+void drawBowstring(const DrawContext &p, ISubmitter &out, Texture *white,
+                   const CarthageBallistaPalette &c) {
+
+  QMatrix4x4 tilted = p.model;
+  tilted.rotate(30.0F, 1.0F, 0.0F, 0.0F);
+
+  draw_cyl(out, tilted, QVector3D(-0.43F, 0.31F, -0.08F),
+           QVector3D(0.0F, 0.29F, 0.14F), 0.007F, c.rope, white);
+  draw_cyl(out, tilted, QVector3D(0.43F, 0.31F, -0.08F),
+           QVector3D(0.0F, 0.29F, 0.14F), 0.007F, c.rope, white);
+}
+
+void drawSlide(const DrawContext &p, ISubmitter &out, Mesh *unit,
+               Texture *white, const CarthageBallistaPalette &c) {
+
+  QMatrix4x4 tilted = p.model;
+  tilted.rotate(30.0F, 1.0F, 0.0F, 0.0F);
+
+  draw_box(out, unit, white, tilted, QVector3D(0.0F, 0.21F, 0.0F),
+           QVector3D(0.038F, 0.028F, 0.38F), c.wood_light);
+
+  draw_box(out, unit, white, tilted, QVector3D(-0.032F, 0.23F, 0.0F),
+           QVector3D(0.012F, 0.018F, 0.36F), c.metal_bronze);
+  draw_box(out, unit, white, tilted, QVector3D(0.032F, 0.23F, 0.0F),
+           QVector3D(0.012F, 0.018F, 0.36F), c.metal_bronze);
+
+  draw_cyl(out, tilted, QVector3D(0.0F, 0.25F, -0.14F),
+           QVector3D(0.0F, 0.25F, 0.18F), 0.014F, c.wood_dark, white);
+
+  draw_cyl(out, tilted, QVector3D(0.0F, 0.25F, -0.23F),
+           QVector3D(0.0F, 0.25F, -0.14F), 0.011F, c.metal_iron, white);
+}
+
+void drawTriggerMechanism(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                          Texture *white, const CarthageBallistaPalette &c) {
+
+  QMatrix4x4 tilted = p.model;
+  tilted.rotate(30.0F, 1.0F, 0.0F, 0.0F);
+
+  draw_box(out, unit, white, tilted, QVector3D(0.0F, 0.17F, 0.28F),
+           QVector3D(0.075F, 0.075F, 0.055F), c.metal_bronze);
+
+  draw_cyl(out, tilted, QVector3D(0.0F, 0.14F, 0.30F),
+           QVector3D(0.0F, 0.07F, 0.36F), 0.014F, c.leather, white);
+
+  draw_cyl(out, tilted, QVector3D(-0.11F, 0.11F, 0.23F),
+           QVector3D(-0.18F, 0.11F, 0.23F), 0.011F, c.wood_frame, white);
+  draw_cyl(out, tilted, QVector3D(0.11F, 0.11F, 0.23F),
+           QVector3D(0.18F, 0.11F, 0.23F), 0.011F, c.wood_frame, white);
+}
+
+void drawCarthageOrnaments(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                           Texture *white, const CarthageBallistaPalette &c) {
+
+  QMatrix4x4 base = p.model;
+  base.rotate(30.0F, 1.0F, 0.0F, 0.0F);
+
+  QMatrix4x4 front_orb = base;
+  front_orb.translate(QVector3D(0.0F, 0.32F, -0.30F));
+  front_orb.scale(0.025F);
+  out.mesh(getUnitSphere(), front_orb, c.metal_gold, white, 1.0F);
+
+  QMatrix4x4 left_orb = base;
+  left_orb.translate(QVector3D(-0.38F, 0.27F, -0.26F));
+  left_orb.scale(0.018F);
+  out.mesh(getUnitSphere(), left_orb, c.metal_bronze, white, 1.0F);
+
+  QMatrix4x4 right_orb = base;
+  right_orb.translate(QVector3D(0.38F, 0.27F, -0.26F));
+  right_orb.scale(0.018F);
+  out.mesh(getUnitSphere(), right_orb, c.metal_bronze, white, 1.0F);
+
+  draw_box(out, unit, white, p.model, QVector3D(-0.38F, 0.22F, 0.0F),
+           QVector3D(0.02F, 0.01F, 0.25F), c.purple_accent);
+  draw_box(out, unit, white, p.model, QVector3D(0.38F, 0.22F, 0.0F),
+           QVector3D(0.02F, 0.01F, 0.25F), c.purple_accent);
+}
+
+} // namespace
+
+void register_ballista_renderer(EntityRendererRegistry &registry) {
+  registry.register_renderer(
+      "troops/carthage/ballista", [](const DrawContext &p, ISubmitter &out) {
+        Mesh *unit = getUnitCube();
+        Texture *white = nullptr;
+
+        if (p.resources != nullptr) {
+          unit = p.resources->unit();
+          white = p.resources->white();
+        }
+        if (auto *scene_renderer = dynamic_cast<Renderer *>(&out)) {
+          unit = scene_renderer->getMeshCube();
+          white = scene_renderer->getWhiteTexture();
+        }
+
+        if (unit == nullptr || white == nullptr) {
+          return;
+        }
+
+        QVector3D team_color{0.4F, 0.2F, 0.6F};
+        if (p.entity != nullptr) {
+          if (auto *r =
+                  p.entity->getComponent<Engine::Core::RenderableComponent>()) {
+            team_color = QVector3D(r->color[0], r->color[1], r->color[2]);
+          }
+        }
+
+        CarthageBallistaPalette c = make_palette(team_color);
+
+        DrawContext ctx = p;
+        ctx.model = p.model;
+        ctx.model.rotate(180.0F, 0.0F, 1.0F, 0.0F);
+
+        drawBaseFrame(ctx, out, unit, white, c);
+        drawWheels(ctx, out, unit, white, c);
+        drawTorsionBundles(ctx, out, unit, white, c);
+        drawArms(ctx, out, unit, white, c);
+        drawBowstring(ctx, out, white, c);
+        drawSlide(ctx, out, unit, white, c);
+        drawTriggerMechanism(ctx, out, unit, white, c);
+        drawCarthageOrnaments(ctx, out, unit, white, c);
+      });
+}
+
+} // namespace Render::GL::Carthage

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

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

+ 254 - 0
render/entity/nations/carthage/catapult_renderer.cpp

@@ -0,0 +1,254 @@
+#include "catapult_renderer.h"
+#include "../../../../game/core/component.h"
+#include "../../../../game/visuals/team_colors.h"
+#include "../../../geom/math_utils.h"
+#include "../../../geom/transforms.h"
+#include "../../../gl/primitives.h"
+#include "../../../gl/resources.h"
+#include "../../../scene_renderer.h"
+#include "../../../submitter.h"
+#include "../../registry.h"
+
+#include <QMatrix4x4>
+#include <QVector3D>
+#include <cmath>
+
+namespace Render::GL::Carthage {
+namespace {
+
+using Render::Geom::clamp01;
+using Render::Geom::clampVec01;
+using Render::Geom::cylinderBetween;
+
+struct CarthageCatapultPalette {
+  QVector3D wood_cedar{0.52F, 0.35F, 0.22F};
+  QVector3D wood_dark{0.38F, 0.25F, 0.15F};
+  QVector3D wood_light{0.60F, 0.45F, 0.30F};
+  QVector3D metal_bronze{0.70F, 0.50F, 0.28F};
+  QVector3D metal_iron{0.35F, 0.33F, 0.32F};
+  QVector3D rope{0.58F, 0.50F, 0.38F};
+  QVector3D leather{0.48F, 0.35F, 0.22F};
+  QVector3D purple_trim{0.45F, 0.18F, 0.55F};
+  QVector3D team{0.8F, 0.9F, 1.0F};
+};
+
+inline auto make_palette(const QVector3D &team) -> CarthageCatapultPalette {
+  CarthageCatapultPalette p;
+  p.team = clampVec01(team);
+  return p;
+}
+
+inline void draw_box(ISubmitter &out, Mesh *unit, Texture *white,
+                     const QMatrix4x4 &model, const QVector3D &pos,
+                     const QVector3D &size, const QVector3D &color) {
+  QMatrix4x4 m = model;
+  m.translate(pos);
+  m.scale(size);
+  out.mesh(unit, m, color, white, 1.0F);
+}
+
+inline void draw_cyl(ISubmitter &out, const QMatrix4x4 &model,
+                     const QVector3D &a, const QVector3D &b, float r,
+                     const QVector3D &color, Texture *white) {
+  out.mesh(getUnitCylinder(), model * cylinderBetween(a, b, r), color, white,
+           1.0F);
+}
+
+void drawBaseFrame(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                   Texture *white, const CarthageCatapultPalette &c) {
+
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.24F, -0.38F),
+           QVector3D(0.52F, 0.06F, 0.06F), c.wood_dark);
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.24F, 0.38F),
+           QVector3D(0.52F, 0.06F, 0.06F), c.wood_dark);
+
+  draw_box(out, unit, white, p.model, QVector3D(-0.42F, 0.24F, 0.0F),
+           QVector3D(0.06F, 0.06F, 0.42F), c.wood_cedar);
+  draw_box(out, unit, white, p.model, QVector3D(0.42F, 0.24F, 0.0F),
+           QVector3D(0.06F, 0.06F, 0.42F), c.wood_cedar);
+
+  draw_box(out, unit, white, p.model, QVector3D(-0.42F, 0.28F, 0.0F),
+           QVector3D(0.08F, 0.03F, 0.44F), c.metal_bronze);
+  draw_box(out, unit, white, p.model, QVector3D(0.42F, 0.28F, 0.0F),
+           QVector3D(0.08F, 0.03F, 0.44F), c.metal_bronze);
+
+  draw_cyl(out, p.model, QVector3D(-0.38F, 0.22F, -0.32F),
+           QVector3D(-0.38F, 0.22F, 0.32F), 0.028F, c.wood_dark, white);
+  draw_cyl(out, p.model, QVector3D(0.38F, 0.22F, -0.32F),
+           QVector3D(0.38F, 0.22F, 0.32F), 0.028F, c.wood_dark, white);
+}
+
+void drawWheels(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                Texture *white, const CarthageCatapultPalette &c) {
+
+  float wheel_radius = 0.20F;
+  float wheel_thickness = 0.045F;
+
+  QVector3D left_front(-0.45F, wheel_radius, -0.28F);
+  QVector3D left_back(-0.45F, wheel_radius, 0.28F);
+  QVector3D right_front(0.45F, wheel_radius, -0.28F);
+  QVector3D right_back(0.45F, wheel_radius, 0.28F);
+
+  auto drawWheel = [&](const QVector3D &pos, float side_offset) {
+    QVector3D inner = pos + QVector3D(side_offset * wheel_thickness, 0, 0);
+    QVector3D outer =
+        pos + QVector3D(side_offset * (wheel_thickness + 0.07F), 0, 0);
+
+    draw_cyl(out, p.model, inner, outer, wheel_radius, c.wood_dark, white);
+
+    draw_cyl(out, p.model, inner - QVector3D(side_offset * 0.005F, 0, 0),
+             outer + QVector3D(side_offset * 0.005F, 0, 0),
+             wheel_radius + 0.018F, c.metal_bronze, white);
+
+    draw_cyl(out, p.model, inner - QVector3D(side_offset * 0.025F, 0, 0),
+             outer + QVector3D(side_offset * 0.025F, 0, 0), 0.05F,
+             c.metal_bronze, white);
+
+    for (int s = 0; s < 6; ++s) {
+      float angle = s * 3.14159F / 3.0F;
+      float spoke_y = std::sin(angle) * wheel_radius * 0.75F;
+      float spoke_z = std::cos(angle) * wheel_radius * 0.75F;
+      QVector3D spoke_pos =
+          pos +
+          QVector3D(side_offset * (wheel_thickness + 0.035F), spoke_y, spoke_z);
+      draw_cyl(out, p.model,
+               pos + QVector3D(side_offset * (wheel_thickness + 0.035F), 0, 0),
+               spoke_pos, 0.012F, c.wood_cedar, white);
+    }
+  };
+
+  drawWheel(left_front, -1.0F);
+  drawWheel(left_back, -1.0F);
+  drawWheel(right_front, 1.0F);
+  drawWheel(right_back, 1.0F);
+
+  draw_cyl(out, p.model, QVector3D(-0.44F, wheel_radius, -0.28F),
+           QVector3D(0.44F, wheel_radius, -0.28F), 0.028F, c.metal_iron, white);
+  draw_cyl(out, p.model, QVector3D(-0.44F, wheel_radius, 0.28F),
+           QVector3D(0.44F, wheel_radius, 0.28F), 0.028F, c.metal_iron, white);
+}
+
+void drawThrowingArm(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                     Texture *white, const CarthageCatapultPalette &c,
+                     float animTime) {
+
+  draw_cyl(out, p.model, QVector3D(-0.30F, 0.22F, -0.10F),
+           QVector3D(-0.20F, 0.70F, 0.05F), 0.055F, c.wood_cedar, white);
+  draw_cyl(out, p.model, QVector3D(0.30F, 0.22F, -0.10F),
+           QVector3D(0.20F, 0.70F, 0.05F), 0.055F, c.wood_cedar, white);
+
+  draw_cyl(out, p.model, QVector3D(-0.22F, 0.68F, 0.03F),
+           QVector3D(0.22F, 0.68F, 0.03F), 0.045F, c.wood_dark, white);
+
+  draw_cyl(out, p.model, QVector3D(-0.08F, 0.65F, 0.03F),
+           QVector3D(0.08F, 0.65F, 0.03F), 0.06F, c.metal_bronze, white);
+
+  float arm_angle = std::sin(animTime * 0.4F) * 0.25F + 0.75F;
+  QMatrix4x4 armMatrix = p.model;
+  armMatrix.translate(0.0F, 0.60F, 0.03F);
+  armMatrix.rotate(arm_angle * 57.3F, 1.0F, 0.0F, 0.0F);
+
+  draw_cyl(out, armMatrix, QVector3D(0.0F, 0.0F, -0.65F),
+           QVector3D(0.0F, 0.0F, 0.35F), 0.05F, c.wood_cedar, white);
+
+  draw_box(out, unit, white, armMatrix, QVector3D(0.0F, -0.06F, -0.60F),
+           QVector3D(0.10F, 0.08F, 0.12F), c.leather);
+
+  draw_box(out, unit, white, armMatrix, QVector3D(0.0F, 0.0F, 0.30F),
+           QVector3D(0.08F, 0.08F, 0.08F), c.metal_bronze);
+}
+
+void drawTorsionMechanism(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                          Texture *white, const CarthageCatapultPalette &c) {
+
+  draw_box(out, unit, white, p.model, QVector3D(-0.22F, 0.40F, 0.0F),
+           QVector3D(0.05F, 0.20F, 0.18F), c.wood_dark);
+  draw_box(out, unit, white, p.model, QVector3D(0.22F, 0.40F, 0.0F),
+           QVector3D(0.05F, 0.20F, 0.18F), c.wood_dark);
+
+  for (int i = 0; i < 4; ++i) {
+    float offset = (float(i) - 1.5F) * 0.035F;
+    draw_cyl(out, p.model, QVector3D(-0.15F, 0.28F + offset, -0.10F),
+             QVector3D(-0.15F, 0.52F + offset, 0.10F), 0.028F, c.rope, white);
+    draw_cyl(out, p.model, QVector3D(0.15F, 0.28F + offset, -0.10F),
+             QVector3D(0.15F, 0.52F + offset, 0.10F), 0.028F, c.rope, white);
+  }
+
+  draw_cyl(out, p.model, QVector3D(-0.24F, 0.32F, 0.0F),
+           QVector3D(-0.18F, 0.32F, 0.0F), 0.14F, c.metal_bronze, white);
+  draw_cyl(out, p.model, QVector3D(0.18F, 0.32F, 0.0F),
+           QVector3D(0.24F, 0.32F, 0.0F), 0.14F, c.metal_bronze, white);
+}
+
+void drawDecorations(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                     Texture *white, const CarthageCatapultPalette &c) {
+
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.72F, -0.12F),
+           QVector3D(0.04F, 0.08F, 0.02F), c.metal_bronze);
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.78F, -0.12F),
+           QVector3D(0.06F, 0.02F, 0.02F), c.metal_bronze);
+
+  draw_box(out, unit, white, p.model, QVector3D(-0.57F, 0.22F, -0.35F),
+           QVector3D(0.05F, 0.05F, 0.05F), c.metal_bronze);
+  draw_box(out, unit, white, p.model, QVector3D(0.57F, 0.22F, -0.35F),
+           QVector3D(0.05F, 0.05F, 0.05F), c.metal_bronze);
+  draw_box(out, unit, white, p.model, QVector3D(-0.57F, 0.22F, 0.35F),
+           QVector3D(0.05F, 0.05F, 0.05F), c.metal_bronze);
+  draw_box(out, unit, white, p.model, QVector3D(0.57F, 0.22F, 0.35F),
+           QVector3D(0.05F, 0.05F, 0.05F), c.metal_bronze);
+}
+
+void drawWindlass(const DrawContext &p, ISubmitter &out, Mesh *unit,
+                  Texture *white, const CarthageCatapultPalette &c) {
+
+  draw_cyl(out, p.model, QVector3D(-0.22F, 0.25F, 0.35F),
+           QVector3D(0.22F, 0.25F, 0.35F), 0.06F, c.wood_cedar, white);
+  draw_cyl(out, p.model, QVector3D(-0.15F, 0.25F, 0.35F),
+           QVector3D(0.15F, 0.25F, 0.35F), 0.07F, c.metal_bronze, white);
+
+  draw_cyl(out, p.model, QVector3D(-0.28F, 0.25F, 0.35F),
+           QVector3D(-0.28F, 0.38F, 0.35F), 0.025F, c.wood_dark, white);
+  draw_cyl(out, p.model, QVector3D(0.28F, 0.25F, 0.35F),
+           QVector3D(0.28F, 0.38F, 0.35F), 0.025F, c.wood_dark, white);
+
+  draw_cyl(out, p.model, QVector3D(-0.12F, 0.25F, 0.35F),
+           QVector3D(0.12F, 0.25F, 0.35F), 0.065F, c.rope, white);
+}
+
+} // namespace
+
+void register_catapult_renderer(EntityRendererRegistry &registry) {
+  registry.register_renderer(
+      "troops/carthage/catapult", [](const DrawContext &p, ISubmitter &out) {
+        Mesh *unit_cube = getUnitCube();
+        Texture *white_tex = nullptr;
+
+        if (auto *scene_renderer = dynamic_cast<Renderer *>(&out)) {
+          unit_cube = scene_renderer->getMeshCube();
+          white_tex = scene_renderer->getWhiteTexture();
+        }
+
+        if (unit_cube == nullptr || white_tex == nullptr) {
+          return;
+        }
+
+        QVector3D team_color{0.4F, 0.2F, 0.6F};
+        if (p.entity != nullptr) {
+          if (auto *r =
+                  p.entity->getComponent<Engine::Core::RenderableComponent>()) {
+            team_color = QVector3D(r->color[0], r->color[1], r->color[2]);
+          }
+        }
+
+        auto palette = make_palette(team_color);
+
+        drawBaseFrame(p, out, unit_cube, white_tex, palette);
+        drawWheels(p, out, unit_cube, white_tex, palette);
+        drawTorsionMechanism(p, out, unit_cube, white_tex, palette);
+        drawThrowingArm(p, out, unit_cube, white_tex, palette, p.animationTime);
+        drawWindlass(p, out, unit_cube, white_tex, palette);
+        drawDecorations(p, out, unit_cube, white_tex, palette);
+      });
+}
+
+} // namespace Render::GL::Carthage

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

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

+ 235 - 1
render/entity/nations/carthage/healer_renderer.cpp

@@ -35,6 +35,9 @@
 #include <string_view>
 #include <unordered_map>
 
+using Render::Geom::cylinderBetween;
+using Render::Geom::sphereAt;
+
 namespace Render::GL::Carthage {
 
 namespace {
@@ -72,7 +75,8 @@ using Render::GL::Humanoid::saturate_color;
 class HealerRenderer : public HumanoidRendererBase {
 public:
   auto get_proportion_scaling() const -> QVector3D override {
-    return {0.92F, 1.00F, 0.94F};
+
+    return {0.88F, 0.99F, 0.90F};
   }
 
   void get_variant(const DrawContext &ctx, uint32_t seed,
@@ -81,6 +85,58 @@ public:
     v.palette = makeHumanoidPalette(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;
+    bool wants_beard = style.force_beard;
+    if (!wants_beard) {
+      float const beard_roll = nextRand(beard_seed);
+      wants_beard = (beard_roll < 0.85F);
+    }
+
+    if (wants_beard) {
+      float const style_roll = nextRand(beard_seed);
+
+      if (style_roll < 0.45F) {
+        v.facial_hair.style = FacialHairStyle::ShortBeard;
+        v.facial_hair.length = 0.8F + nextRand(beard_seed) * 0.4F;
+      } else if (style_roll < 0.75F) {
+        v.facial_hair.style = FacialHairStyle::FullBeard;
+        v.facial_hair.length = 0.9F + nextRand(beard_seed) * 0.5F;
+      } else if (style_roll < 0.90F) {
+        v.facial_hair.style = FacialHairStyle::Goatee;
+        v.facial_hair.length = 0.7F + nextRand(beard_seed) * 0.4F;
+      } else {
+        v.facial_hair.style = FacialHairStyle::MustacheAndBeard;
+        v.facial_hair.length = 1.0F + nextRand(beard_seed) * 0.4F;
+      }
+
+      float const color_roll = nextRand(beard_seed);
+      if (color_roll < 0.55F) {
+
+        v.facial_hair.color = QVector3D(0.12F + nextRand(beard_seed) * 0.08F,
+                                        0.10F + nextRand(beard_seed) * 0.06F,
+                                        0.08F + nextRand(beard_seed) * 0.05F);
+      } else if (color_roll < 0.80F) {
+
+        v.facial_hair.color = QVector3D(0.22F + nextRand(beard_seed) * 0.10F,
+                                        0.17F + nextRand(beard_seed) * 0.08F,
+                                        0.12F + nextRand(beard_seed) * 0.06F);
+      } else {
+
+        v.facial_hair.color = QVector3D(0.35F + nextRand(beard_seed) * 0.15F,
+                                        0.32F + nextRand(beard_seed) * 0.12F,
+                                        0.30F + nextRand(beard_seed) * 0.10F);
+        v.facial_hair.greyness = 0.3F + nextRand(beard_seed) * 0.4F;
+      }
+
+      v.facial_hair.thickness = 0.85F + nextRand(beard_seed) * 0.25F;
+      v.facial_hair.coverage = 0.80F + nextRand(beard_seed) * 0.20F;
+    }
   }
 
   void customize_pose(const DrawContext &,
@@ -112,6 +168,10 @@ public:
 
   void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
                    const HumanoidPose &pose, ISubmitter &out) const override {
+
+    if (!resolve_style(ctx).show_helmet) {
+      return;
+    }
     auto &registry = EquipmentRegistry::instance();
     auto helmet = registry.get(EquipmentCategory::Helmet, "carthage_light");
     if (helmet) {
@@ -130,8 +190,182 @@ public:
           registry.get(EquipmentCategory::Armor, "carthage_light_armor");
       if (armor) {
         armor->render(ctx, pose.body_frames, v.palette, anim, out);
+        return;
       }
     }
+    drawHealerRobes(ctx, v, pose, out);
+  }
+
+  void drawHealerRobes(const DrawContext &ctx, const HumanoidVariant &v,
+                       const HumanoidPose &pose, 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;
+    }
+
+    QVector3D const robe_cream(0.90F, 0.85F, 0.72F);
+    QVector3D const robe_light(0.88F, 0.82F, 0.68F);
+    QVector3D const robe_tan(0.78F, 0.70F, 0.55F);
+    QVector3D const purple_tyrian(0.50F, 0.20F, 0.55F);
+    QVector3D const purple_dark(0.35F, 0.12F, 0.40F);
+    QVector3D const gold_trim(0.75F, 0.60F, 0.30F);
+    QVector3D const bronze(0.70F, 0.50F, 0.28F);
+
+    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.02F;
+    float const torso_depth =
+        (torso.depth > 0.0F) ? torso.depth * 0.88F : torso.radius * 0.82F;
+
+    float const y_shoulder = origin.y() + 0.040F;
+    float const y_waist = waist.origin.y();
+
+    constexpr int segments = 12;
+    constexpr float pi = std::numbers::pi_v<float>;
+
+    auto drawRobeRing = [&](float y_pos, float width, float depth,
+                            const QVector3D &color, float thickness) {
+      for (int i = 0; i < segments; ++i) {
+        float const angle1 = (static_cast<float>(i) / segments) * 2.0F * pi;
+        float const angle2 = (static_cast<float>(i + 1) / segments) * 2.0F * pi;
+
+        float const sin1 = std::sin(angle1);
+        float const cos1 = std::cos(angle1);
+        float const sin2 = std::sin(angle2);
+        float const cos2 = std::cos(angle2);
+
+        float const r1 =
+            (std::abs(cos1) * depth + (1.0F - std::abs(cos1)) * width);
+        float const r2 =
+            (std::abs(cos2) * depth + (1.0F - std::abs(cos2)) * width);
+
+        QVector3D const p1 = origin + right * (r1 * sin1) +
+                             forward * (r1 * cos1) + up * (y_pos - origin.y());
+        QVector3D const p2 = origin + right * (r2 * sin2) +
+                             forward * (r2 * cos2) + up * (y_pos - origin.y());
+
+        out.mesh(getUnitCylinder(),
+                 cylinderBetween(ctx.model, p1, p2, thickness), color, nullptr,
+                 1.0F);
+      }
+    };
+
+    drawRobeRing(y_shoulder - 0.00F, torso_r * 1.22F, torso_depth * 1.12F,
+                 robe_cream, 0.036F);
+    drawRobeRing(y_shoulder - 0.05F, torso_r * 1.30F, torso_depth * 1.18F,
+                 robe_cream, 0.038F);
+
+    drawRobeRing(y_shoulder - 0.09F, torso_r * 1.12F, torso_depth * 1.00F,
+                 robe_cream, 0.032F);
+
+    float const torso_fill_top = y_shoulder - 0.12F;
+    float const torso_fill_bot = y_waist + 0.04F;
+    constexpr int torso_fill_layers = 8;
+    for (int i = 0; i < torso_fill_layers; ++i) {
+      float const t =
+          static_cast<float>(i) / static_cast<float>(torso_fill_layers - 1);
+      float const y = torso_fill_top + (torso_fill_bot - torso_fill_top) * t;
+      float const width = torso_r * (1.08F - t * 0.22F);
+      float const depth = torso_depth * (1.00F - t * 0.18F);
+      float const thickness = 0.030F - t * 0.010F;
+      QVector3D const c =
+          (t < 0.35F) ? robe_cream : robe_light * (1.0F - (t - 0.35F) * 0.3F);
+      drawRobeRing(y, width, depth, c, thickness);
+    }
+
+    float const skirt_flare = 1.40F;
+    constexpr int skirt_layers = 9;
+    for (int layer = 0; layer < skirt_layers; ++layer) {
+      float const t =
+          static_cast<float>(layer) / static_cast<float>(skirt_layers - 1);
+      float const y = y_waist - t * 0.32F;
+      float const flare = 1.0F + t * (skirt_flare - 1.0F);
+      QVector3D const skirt_color = robe_cream * (1.0F - t * 0.08F);
+      drawRobeRing(y, torso_r * 0.90F * flare, torso_depth * 0.84F * flare,
+                   skirt_color, 0.022F + t * 0.012F);
+    }
+
+    float const sash_y = y_waist + 0.01F;
+    QVector3D const sash_top = origin + up * (sash_y + 0.028F - origin.y());
+    QVector3D const sash_bot = origin + up * (sash_y - 0.028F - origin.y());
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, sash_bot, sash_top, torso_r * 0.99F),
+             purple_tyrian, nullptr, 1.0F);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, sash_top, sash_top - up * 0.006F,
+                             torso_r * 1.02F),
+             gold_trim, nullptr, 1.0F);
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, sash_bot + up * 0.006F, sash_bot,
+                             torso_r * 1.02F),
+             gold_trim, nullptr, 1.0F);
+
+    QVector3D const sash_hang_start =
+        origin + right * (torso_r * 0.3F) + up * (sash_y - origin.y());
+    QVector3D const sash_hang_end =
+        sash_hang_start - up * 0.12F + forward * 0.02F;
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, sash_hang_start, sash_hang_end, 0.018F),
+             purple_dark, nullptr, 1.0F);
+
+    out.mesh(getUnitSphere(),
+             sphereAt(ctx.model, sash_hang_end - up * 0.01F, 0.015F), gold_trim,
+             nullptr, 1.0F);
+
+    float const neck_y = y_shoulder + 0.04F;
+    QVector3D const neck_center = origin + up * (neck_y - origin.y());
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, neck_center - up * 0.012F,
+                             neck_center + up * 0.012F, HP::NECK_RADIUS * 1.7F),
+             robe_tan, nullptr, 1.0F);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model, neck_center + up * 0.010F,
+                             neck_center + up * 0.018F, HP::NECK_RADIUS * 2.0F),
+             purple_tyrian * 0.9F, nullptr, 1.0F);
+
+    auto drawFlowingSleeve = [&](const QVector3D &shoulder_pos,
+                                 const QVector3D &outward) {
+      QVector3D const backward = -forward;
+      QVector3D const anchor = shoulder_pos + up * 0.070F + backward * 0.020F;
+      for (int i = 0; i < 5; ++i) {
+        float const t = static_cast<float>(i) / 5.0F;
+        QVector3D const sleeve_pos = anchor + outward * (0.014F + t * 0.030F) +
+                                     forward * (-0.020F + t * 0.065F) -
+                                     up * (t * 0.05F);
+        float const sleeve_r = HP::UPPER_ARM_R * (1.55F - t * 0.08F);
+        QVector3D const sleeve_color = robe_cream * (1.0F - t * 0.04F);
+        out.mesh(getUnitSphere(), sphereAt(ctx.model, sleeve_pos, sleeve_r),
+                 sleeve_color, nullptr, 1.0F);
+      }
+
+      QVector3D const cuff_pos =
+          anchor + outward * 0.055F + forward * 0.040F - up * 0.05F;
+      out.mesh(getUnitSphere(),
+               sphereAt(ctx.model, cuff_pos, HP::UPPER_ARM_R * 1.15F),
+               purple_tyrian * 0.85F, nullptr, 1.0F);
+    };
+    drawFlowingSleeve(frames.shoulder_l.origin, -right);
+    drawFlowingSleeve(frames.shoulder_r.origin, right);
+
+    QVector3D const pendant_pos = origin + forward * (torso_depth * 0.6F) +
+                                  up * (y_shoulder - 0.06F - origin.y());
+    out.mesh(getUnitSphere(), sphereAt(ctx.model, pendant_pos, 0.022F), bronze,
+             nullptr, 1.0F);
+
+    out.mesh(getUnitCylinder(),
+             cylinderBetween(ctx.model,
+                             neck_center + forward * (torso_depth * 0.3F),
+                             pendant_pos + up * 0.01F, 0.006F),
+             gold_trim * 0.8F, nullptr, 1.0F);
   }
 
 private:

+ 20 - 9
render/entity/nations/carthage/healer_style.cpp

@@ -4,26 +4,37 @@
 #include <QVector3D>
 
 namespace {
-constexpr QVector3D k_carthage_cloth{0.88F, 0.85F, 0.90F};
-constexpr QVector3D k_carthage_leather{0.35F, 0.26F, 0.18F};
-constexpr QVector3D k_carthage_leather_dark{0.20F, 0.17F, 0.14F};
-constexpr QVector3D k_carthage_metal{0.68F, 0.65F, 0.60F};
-constexpr QVector3D k_carthage_wood{0.38F, 0.28F, 0.16F};
-constexpr QVector3D k_carthage_cape{0.32F, 0.22F, 0.78F};
+
+constexpr QVector3D k_carthage_tunic{0.92F, 0.88F, 0.82F};
+
+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_purple{0.45F, 0.18F, 0.55F};
 } // namespace
 
 namespace Render::GL::Carthage {
 
 void register_carthage_healer_style() {
   HealerStyleConfig style;
-  style.cloth_color = k_carthage_cloth;
+  style.cloth_color = k_carthage_tunic;
   style.leather_color = k_carthage_leather;
   style.leather_dark_color = k_carthage_leather_dark;
-  style.metal_color = k_carthage_metal;
+  style.metal_color = k_carthage_bronze;
   style.wood_color = k_carthage_wood;
-  style.cape_color = k_carthage_cape;
+  style.cape_color = k_carthage_purple;
   style.shader_id = "healer_carthage";
 
+  style.show_helmet = false;
+  style.show_armor = false;
+  style.show_cape = true;
+
+  style.force_beard = true;
+
   register_healer_style("default", style);
   register_healer_style("carthage", style);
 }

+ 4 - 2
render/entity/nations/carthage/healer_style.h

@@ -14,10 +14,12 @@ struct HealerStyleConfig {
   std::optional<QVector3D> wood_color;
   std::optional<QVector3D> cape_color;
 
-  bool show_helmet = true;
-  bool show_armor = true;
+  bool show_helmet = false;
+  bool show_armor = false;
   bool show_cape = true;
 
+  bool force_beard = true;
+
   std::string attachment_profile;
   std::string shader_id;
 };

+ 1 - 1
render/entity/nations/carthage/horse_spearman_renderer.cpp

@@ -18,7 +18,7 @@ auto make_horse_spearman_config() -> HorseSpearmanRendererConfig {
   config.spear_equipment_id = "spear";
   config.helmet_equipment_id = "carthage_heavy";
   config.armor_equipment_id = "armor_heavy_carthage";
-  config.shoulder_equipment_id = "carthage_shoulder_cover";
+  config.shoulder_equipment_id = "carthage_shoulder_cover_cavalry";
   config.has_shoulder = true;
   config.helmet_offset_moving = 0.04F;
   config.horse_attachments.emplace_back(

+ 51 - 2
render/entity/nations/carthage/horse_swordsman_renderer.cpp

@@ -4,22 +4,71 @@
 #include "../../../equipment/horse/tack/reins_renderer.h"
 #include "../../../gl/backend.h"
 #include "../../../gl/shader.h"
+#include "../../../humanoid/style_palette.h"
 #include "../../../scene_renderer.h"
 #include "../../../submitter.h"
 #include "../../mounted_knight_renderer_base.h"
+#include "swordsman_style.h"
 
 #include <memory>
+#include <optional>
 
 namespace Render::GL::Carthage {
 namespace {
 
+constexpr float k_team_mix_weight = 0.6F;
+constexpr float k_style_mix_weight = 0.4F;
+
+auto carthage_style() -> KnightStyleConfig {
+  KnightStyleConfig style;
+  style.cloth_color = QVector3D(0.15F, 0.36F, 0.55F);
+  style.leather_color = QVector3D(0.32F, 0.22F, 0.12F);
+  style.leather_dark_color = QVector3D(0.20F, 0.14F, 0.09F);
+  style.metal_color = QVector3D(0.70F, 0.68F, 0.52F);
+  style.shader_id = "horse_swordsman_carthage";
+  return style;
+}
+
+class CarthageMountedKnightRenderer : public MountedKnightRendererBase {
+public:
+  using MountedKnightRendererBase::MountedKnightRendererBase;
+
+  void get_variant(const DrawContext &ctx, uint32_t seed,
+                   HumanoidVariant &v) const override {
+    MountedKnightRendererBase::get_variant(ctx, seed, v);
+    const KnightStyleConfig style = carthage_style();
+    QVector3D const team_tint = resolveTeamTint(ctx);
+
+    auto apply_color = [&](const std::optional<QVector3D> &override_color,
+                           QVector3D &target) {
+      target = Render::GL::Humanoid::mix_palette_color(
+          target, override_color, team_tint, k_team_mix_weight,
+          k_style_mix_weight);
+    };
+
+    apply_color(style.cloth_color, v.palette.cloth);
+    apply_color(style.leather_color, v.palette.leather);
+    apply_color(style.leather_dark_color, v.palette.leatherDark);
+    apply_color(style.metal_color, v.palette.metal);
+  }
+
+  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
+    const KnightStyleConfig style = carthage_style();
+    if (!style.shader_id.empty()) {
+      return QString::fromStdString(style.shader_id);
+    }
+    return MountedKnightRendererBase::resolve_shader_key(ctx);
+  }
+};
+
 auto makeMountedKnightConfig() -> MountedKnightRendererConfig {
   MountedKnightRendererConfig config;
   config.sword_equipment_id = "sword_carthage";
   config.shield_equipment_id = "shield_carthage_cavalry";
   config.helmet_equipment_id = "carthage_heavy";
   config.armor_equipment_id = "armor_heavy_carthage";
-  config.shoulder_equipment_id = "carthage_shoulder_cover";
+  config.shoulder_equipment_id = "carthage_shoulder_cover_cavalry";
+  config.metal_color = QVector3D(0.70F, 0.68F, 0.52F);
   config.has_shoulder = true;
   config.helmet_offset_moving = 0.03F;
   config.horse_attachments.emplace_back(
@@ -34,7 +83,7 @@ void registerMountedKnightRenderer(EntityRendererRegistry &registry) {
   registry.register_renderer(
       "troops/carthage/horse_swordsman",
       [](const DrawContext &ctx, ISubmitter &out) {
-        static MountedKnightRendererBase const static_renderer(
+        static CarthageMountedKnightRenderer const static_renderer(
             makeMountedKnightConfig());
         Shader *horse_swordsman_shader = nullptr;
         if (ctx.backend != nullptr) {

+ 1 - 6
render/entity/nations/carthage/spearman_renderer.cpp

@@ -58,11 +58,6 @@ auto lookup_spearman_shader_resources(const QString &shader_key)
         QStringLiteral(":/assets/shaders/spearman_carthage.vert"),
         QStringLiteral(":/assets/shaders/spearman_carthage.frag")};
   }
-  if (shader_key == QStringLiteral("spearman_kingdom_of_iron")) {
-    return SpearmanShaderResourcePaths{
-        QStringLiteral(":/assets/shaders/spearman_kingdom_of_iron.vert"),
-        QStringLiteral(":/assets/shaders/spearman_kingdom_of_iron.frag")};
-  }
   if (shader_key == QStringLiteral("spearman_roman_republic")) {
     return SpearmanShaderResourcePaths{
         QStringLiteral(":/assets/shaders/spearman_roman_republic.vert"),
@@ -114,7 +109,7 @@ class SpearmanRenderer : public HumanoidRendererBase {
 public:
   auto get_proportion_scaling() const -> QVector3D override {
 
-    return {0.94F, 1.04F, 0.92F};
+    return {0.72F, 1.02F, 0.74F};
   }
 
   void adjust_variation(const DrawContext &, uint32_t,

+ 8 - 1
render/entity/nations/carthage/swordsman_renderer.cpp

@@ -97,11 +97,18 @@ struct KnightExtras {
 
 class KnightRenderer : public HumanoidRendererBase {
 public:
+  static constexpr float kLimbWidthScale = 0.90F;
+  static constexpr float kTorsoWidthScale = 0.55F;
+  static constexpr float kHeightScale = 1.03F;
+  static constexpr float kDepthScale = 0.46F;
+
   auto get_proportion_scaling() const -> QVector3D override {
 
-    return {0.750F, 1.05F, 0.50F};
+    return {kLimbWidthScale, kHeightScale, kDepthScale};
   }
 
+  auto get_torso_scale() const -> float override { return kTorsoWidthScale; }
+
 private:
   mutable std::unordered_map<uint32_t, KnightExtras> m_extrasCache;
 

+ 0 - 321
render/entity/nations/kingdom/archer_renderer.cpp

@@ -1,321 +0,0 @@
-#include "archer_renderer.h"
-#include "../../../../game/core/component.h"
-#include "../../../../game/core/entity.h"
-#include "../../../../game/systems/nation_id.h"
-#include "../../../equipment/equipment_registry.h"
-#include "../../../equipment/weapons/bow_renderer.h"
-#include "../../../equipment/weapons/quiver_renderer.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 "archer_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>
-
-namespace Render::GL::Kingdom {
-
-namespace {
-
-constexpr std::string_view k_default_style_key = "default";
-constexpr std::string_view k_attachment_headwrap = "carthage_headwrap";
-
-constexpr float k_kneel_depth_multiplier = 1.125F;
-constexpr float k_lean_amount_multiplier = 0.83F;
-
-auto style_registry() -> std::unordered_map<std::string, ArcherStyleConfig> & {
-  static std::unordered_map<std::string, ArcherStyleConfig> styles;
-  return styles;
-}
-
-void ensure_archer_styles_registered() {
-  static const bool registered = []() {
-    register_kingdom_archer_style();
-    return true;
-  }();
-  (void)registered;
-}
-
-constexpr float k_team_mix_weight = 0.65F;
-constexpr float k_style_mix_weight = 0.35F;
-
-} // namespace
-
-void register_archer_style(const std::string &nation_id,
-                           const ArcherStyleConfig &style) {
-  style_registry()[nation_id] = style;
-}
-
-using Render::Geom::clamp01;
-using Render::Geom::clampf;
-using Render::Geom::coneFromTo;
-using Render::Geom::cylinderBetween;
-using Render::Geom::sphereAt;
-using Render::GL::Humanoid::mix_palette_color;
-using Render::GL::Humanoid::saturate_color;
-
-class ArcherRenderer : public HumanoidRendererBase {
-public:
-  auto get_proportion_scaling() const -> QVector3D override {
-
-    return {0.94F, 1.01F, 0.96F};
-  }
-
-  void get_variant(const DrawContext &ctx, uint32_t seed,
-                   HumanoidVariant &v) const override {
-    QVector3D const team_tint = resolveTeamTint(ctx);
-    v.palette = makeHumanoidPalette(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_height_jitter = (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.03F;
-    float const arm_asymmetry = (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.04F;
-
-    float const bow_x = 0.0F;
-
-    if (anim.is_in_hold_mode || anim.is_exiting_hold) {
-      float const t =
-          anim.is_in_hold_mode ? 1.0F : (1.0F - anim.hold_exit_progress);
-
-      controller.kneel(t * k_kneel_depth_multiplier);
-      controller.lean(QVector3D(0.0F, 0.0F, 1.0F),
-                      t * k_lean_amount_multiplier);
-
-      QVector3D const hold_hand_l(
-          bow_x - 0.15F, controller.get_shoulder_y(true) + 0.30F, 0.55F);
-      QVector3D const hold_hand_r(
-          bow_x + 0.12F, controller.get_shoulder_y(false) + 0.15F, 0.10F);
-      QVector3D const normal_hand_l(bow_x - 0.05F + arm_asymmetry,
-                                    HP::SHOULDER_Y + 0.05F + arm_height_jitter,
-                                    0.55F);
-      QVector3D const normal_hand_r(
-          0.15F - arm_asymmetry * 0.5F,
-          HP::SHOULDER_Y + 0.15F + arm_height_jitter * 0.8F, 0.20F);
-
-      QVector3D const blended_hand_l =
-          normal_hand_l * (1.0F - t) + hold_hand_l * t;
-      QVector3D const blended_hand_r =
-          normal_hand_r * (1.0F - t) + hold_hand_r * t;
-
-      controller.placeHandAt(true, blended_hand_l);
-      controller.placeHandAt(false, blended_hand_r);
-    } else {
-      QVector3D const idle_hand_l(bow_x - 0.05F + arm_asymmetry,
-                                  HP::SHOULDER_Y + 0.05F + arm_height_jitter,
-                                  0.55F);
-      QVector3D const idle_hand_r(
-          0.15F - arm_asymmetry * 0.5F,
-          HP::SHOULDER_Y + 0.15F + arm_height_jitter * 0.8F, 0.20F);
-
-      controller.placeHandAt(true, idle_hand_l);
-      controller.placeHandAt(false, idle_hand_r);
-    }
-
-    if (anim.is_attacking && !anim.is_in_hold_mode) {
-      float const attack_phase =
-          std::fmod(anim.time * ARCHER_INV_ATTACK_CYCLE_TIME, 1.0F);
-
-      if (anim.is_melee) {
-        controller.meleeStrike(attack_phase);
-      } else {
-        controller.aimBow(attack_phase);
-      }
-    }
-  }
-
-  void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
-                      const HumanoidPose &pose,
-                      const HumanoidAnimationContext &anim_ctx,
-                      ISubmitter &out) const override {
-    using HP = HumanProportions;
-
-    auto const &style = resolve_style(ctx);
-    QVector3D team_tint = resolveTeamTint(ctx);
-
-    auto tint = [&](float k) {
-      return QVector3D(clamp01(team_tint.x() * k), clamp01(team_tint.y() * k),
-                       clamp01(team_tint.z() * k));
-    };
-    QVector3D const fletch = tint(0.9F);
-
-    auto &registry = EquipmentRegistry::instance();
-    auto quiver = registry.get(EquipmentCategory::Weapon, "quiver");
-    if (quiver) {
-
-      QuiverRenderConfig quiver_config;
-      quiver_config.fletching_color = fletch;
-      quiver_config.quiver_radius = HP::HEAD_RADIUS * 0.45F;
-
-      auto *quiver_renderer = dynamic_cast<QuiverRenderer *>(quiver.get());
-      if (quiver_renderer) {
-        quiver_renderer->setConfig(quiver_config);
-      }
-
-      quiver->render(ctx, pose.body_frames, v.palette, anim_ctx, out);
-    }
-
-    auto bow = registry.get(EquipmentCategory::Weapon, "bow_kingdom");
-    if (bow) {
-
-      BowRenderConfig bow_config;
-      bow_config.string_color = QVector3D(0.30F, 0.30F, 0.32F);
-      bow_config.metal_color =
-          Render::Geom::clampVec01(v.palette.metal * 1.15F);
-      bow_config.fletching_color = fletch;
-      bow_config.bow_top_y = HP::SHOULDER_Y + 0.55F;
-      bow_config.bow_bot_y = HP::WAIST_Y - 0.25F;
-      bow_config.bow_x = 0.0F;
-
-      if (style.bow_string_color) {
-        bow_config.string_color = saturate_color(*style.bow_string_color);
-      }
-      if (style.fletching_color) {
-        bow_config.fletching_color = saturate_color(*style.fletching_color);
-      }
-
-      auto *bow_renderer = dynamic_cast<BowRenderer *>(bow.get());
-      if (bow_renderer) {
-        bow_renderer->setConfig(bow_config);
-      }
-
-      bow->render(ctx, pose.body_frames, v.palette, anim_ctx, out);
-    }
-  }
-
-  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, "kingdom_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 {
-    if (resolve_style(ctx).show_armor) {
-      auto &registry = EquipmentRegistry::instance();
-      auto armor =
-          registry.get(EquipmentCategory::Armor, "kingdom_light_armor");
-      if (armor) {
-        armor->render(ctx, pose.body_frames, v.palette, anim, out);
-      }
-    }
-  }
-
-private:
-  auto
-  resolve_style(const DrawContext &ctx) const -> const ArcherStyleConfig & {
-    ensure_archer_styles_registered();
-    auto &styles = style_registry();
-    std::string nation_id;
-    if (ctx.entity != nullptr) {
-      if (auto *unit =
-              ctx.entity->getComponent<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 ArcherStyleConfig default_style{};
-    return default_style;
-  }
-
-public:
-  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
-    const ArcherStyleConfig &style = resolve_style(ctx);
-    if (!style.shader_id.empty()) {
-      return QString::fromStdString(style.shader_id);
-    }
-    return QStringLiteral("archer");
-  }
-
-private:
-  void apply_palette_overrides(const ArcherStyleConfig &style,
-                               const QVector3D &team_tint,
-                               HumanoidVariant &variant) const {
-    auto apply_color = [&](const std::optional<QVector3D> &override_color,
-                           QVector3D &target) {
-      target = mix_palette_color(target, override_color, team_tint,
-                                 k_team_mix_weight, k_style_mix_weight);
-    };
-
-    apply_color(style.cloth_color, variant.palette.cloth);
-    apply_color(style.leather_color, variant.palette.leather);
-    apply_color(style.leather_dark_color, variant.palette.leatherDark);
-    apply_color(style.metal_color, variant.palette.metal);
-    apply_color(style.wood_color, variant.palette.wood);
-  }
-};
-
-void registerArcherRenderer(Render::GL::EntityRendererRegistry &registry) {
-  ensure_archer_styles_registered();
-  static ArcherRenderer const renderer;
-  registry.register_renderer(
-      "troops/kingdom/archer", [](const DrawContext &ctx, ISubmitter &out) {
-        static ArcherRenderer const static_renderer;
-        Shader *archer_shader = nullptr;
-        if (ctx.backend != nullptr) {
-          QString shader_key = static_renderer.resolve_shader_key(ctx);
-          archer_shader = ctx.backend->shader(shader_key);
-          if (archer_shader == nullptr) {
-            archer_shader = ctx.backend->shader(QStringLiteral("archer"));
-          }
-        }
-        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
-        if ((scene_renderer != nullptr) && (archer_shader != nullptr)) {
-          scene_renderer->setCurrentShader(archer_shader);
-        }
-        static_renderer.render(ctx, out);
-        if (scene_renderer != nullptr) {
-          scene_renderer->setCurrentShader(nullptr);
-        }
-      });
-}
-
-} // namespace Render::GL::Kingdom

+ 0 - 15
render/entity/nations/kingdom/archer_renderer.h

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

+ 0 - 35
render/entity/nations/kingdom/archer_style.cpp

@@ -1,35 +0,0 @@
-#include "archer_style.h"
-#include "archer_renderer.h"
-
-#include <QVector3D>
-
-namespace {
-constexpr QVector3D k_kingdom_cloth{0.58F, 0.56F, 0.62F};
-constexpr QVector3D k_kingdom_leather{0.32F, 0.23F, 0.16F};
-constexpr QVector3D k_kingdom_leather_dark{0.18F, 0.16F, 0.14F};
-constexpr QVector3D k_kingdom_metal{0.70F, 0.70F, 0.72F};
-constexpr QVector3D k_kingdom_wood{0.40F, 0.30F, 0.18F};
-constexpr QVector3D k_kingdom_cape{0.20F, 0.24F, 0.32F};
-constexpr QVector3D k_kingdom_fletch{0.84F, 0.82F, 0.78F};
-constexpr QVector3D k_kingdom_string{0.22F, 0.22F, 0.24F};
-} // namespace
-
-namespace Render::GL::Kingdom {
-
-void register_kingdom_archer_style() {
-  ArcherStyleConfig style;
-  style.cloth_color = k_kingdom_cloth;
-  style.leather_color = k_kingdom_leather;
-  style.leather_dark_color = k_kingdom_leather_dark;
-  style.metal_color = k_kingdom_metal;
-  style.wood_color = k_kingdom_wood;
-  style.cape_color = k_kingdom_cape;
-  style.fletching_color = k_kingdom_fletch;
-  style.bow_string_color = k_kingdom_string;
-  style.shader_id = "archer_kingdom_of_iron";
-
-  register_archer_style("default", style);
-  register_archer_style("kingdom_of_iron", style);
-}
-
-} // namespace Render::GL::Kingdom

+ 0 - 30
render/entity/nations/kingdom/archer_style.h

@@ -1,30 +0,0 @@
-#pragma once
-
-#include <QVector3D>
-#include <optional>
-#include <string>
-
-namespace Render::GL::Kingdom {
-
-struct ArcherStyleConfig {
-  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> cape_color;
-  std::optional<QVector3D> fletching_color;
-  std::optional<QVector3D> bow_string_color;
-
-  bool show_helmet = true;
-  bool show_armor = true;
-  bool show_shoulder_decor = true;
-  bool show_cape = true;
-
-  std::string attachment_profile;
-  std::string shader_id;
-};
-
-void register_kingdom_archer_style();
-
-} // namespace Render::GL::Kingdom

+ 0 - 694
render/entity/nations/kingdom/barracks_renderer.cpp

@@ -1,694 +0,0 @@
-#include "barracks_renderer.h"
-#include "../../../../game/core/component.h"
-#include "../../../../game/visuals/team_colors.h"
-#include "../../../geom/flag.h"
-#include "../../../geom/math_utils.h"
-#include "../../../geom/transforms.h"
-#include "../../../gl/primitives.h"
-#include "../../../gl/resources.h"
-#include "../../../submitter.h"
-#include "../../barracks_flag_renderer.h"
-#include "../../registry.h"
-
-#include <QMatrix4x4>
-#include <QVector3D>
-#include <algorithm>
-#include <qmatrix4x4.h>
-#include <qvectornd.h>
-
-namespace Render::GL::Kingdom {
-namespace {
-
-using Render::Geom::clamp01;
-using Render::Geom::clampVec01;
-using Render::Geom::cylinderBetween;
-using Render::Geom::lerp;
-
-struct BuildingProportions {
-  static constexpr float base_width = 2.4F;
-  static constexpr float base_depth = 2.0F;
-  static constexpr float base_height = 1.8F;
-  static constexpr float foundation_height = 0.2F;
-  static constexpr float wall_thickness = 0.08F;
-  static constexpr float beam_thickness = 0.12F;
-  static constexpr float corner_post_radius = 0.08F;
-  static constexpr float roof_pitch = 0.8F;
-  static constexpr float roof_overhang = 0.15F;
-  static constexpr float thatch_layer_height = 0.12F;
-  static constexpr float annex_width = 1.0F;
-  static constexpr float annex_depth = 1.0F;
-  static constexpr float annex_height = 1.2F;
-  static constexpr float annex_roof_height = 0.5F;
-  static constexpr float door_width = 0.5F;
-  static constexpr float door_height = 0.8F;
-  static constexpr float window_width = 0.4F;
-  static constexpr float window_height = 0.5F;
-  static constexpr float chimney_width = 0.25F;
-  static constexpr float chimney_height = 1.0F;
-  static constexpr float chimney_cap_size = 0.35F;
-  static constexpr float banner_pole_height = 2.0F;
-  static constexpr float banner_pole_radius = 0.05F;
-  static constexpr float banner_width = 0.5F;
-  static constexpr float banner_height = 0.6F;
-};
-
-struct BarracksPalette {
-  QVector3D plaster{0.92F, 0.88F, 0.78F};
-  QVector3D plasterShade{0.78F, 0.74F, 0.64F};
-  QVector3D timber{0.35F, 0.25F, 0.15F};
-  QVector3D timberLight{0.50F, 0.38F, 0.22F};
-  QVector3D woodDark{0.30F, 0.20F, 0.12F};
-  QVector3D thatch{0.82F, 0.70F, 0.28F};
-  QVector3D thatchDark{0.68F, 0.58F, 0.22F};
-  QVector3D stone{0.55F, 0.54F, 0.52F};
-  QVector3D stoneDark{0.42F, 0.41F, 0.39F};
-  QVector3D door{0.28F, 0.20F, 0.12F};
-  QVector3D window{0.35F, 0.42F, 0.48F};
-  QVector3D path{0.62F, 0.60F, 0.54F};
-  QVector3D crate{0.48F, 0.34F, 0.18F};
-  QVector3D team{0.8F, 0.9F, 1.0F};
-  QVector3D teamTrim{0.48F, 0.54F, 0.60F};
-};
-
-inline auto make_palette(const QVector3D &team) -> BarracksPalette {
-  BarracksPalette p;
-  p.team = clampVec01(team);
-  p.teamTrim =
-      clampVec01(QVector3D(team.x() * 0.6F, team.y() * 0.6F, team.z() * 0.6F));
-  return p;
-}
-
-inline void draw_cylinder(ISubmitter &out, const QMatrix4x4 &model,
-                          const QVector3D &a, const QVector3D &b, float radius,
-                          const QVector3D &color, Texture *white) {
-  out.mesh(getUnitCylinder(), model * cylinderBetween(a, b, radius), color,
-           white, 1.0F);
-}
-
-inline void unitBox(ISubmitter &out, Mesh *unitMesh, Texture *white,
-                    const QMatrix4x4 &model, const QVector3D &t,
-                    const QVector3D &s, const QVector3D &color) {
-  QMatrix4x4 m = model;
-  m.translate(t);
-  m.scale(s);
-  out.mesh(unitMesh, m, color, white, 1.0F);
-}
-
-inline void unitBox(ISubmitter &out, Mesh *unitMesh, Texture *white,
-                    const QMatrix4x4 &model, const QVector3D &s,
-                    const QVector3D &color) {
-  QMatrix4x4 m = model;
-  m.scale(s);
-  out.mesh(unitMesh, m, color, white, 1.0F);
-}
-
-inline void drawFoundation(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                           Texture *white, const BarracksPalette &C) {
-  constexpr float base_width = BuildingProportions::base_width;
-  constexpr float base_depth = BuildingProportions::base_depth;
-  constexpr float foundation_height = BuildingProportions::foundation_height;
-
-  unitBox(out, unit, white, p.model,
-          QVector3D(0.0F, -foundation_height / 2, 0.0F),
-          QVector3D(base_width / 2 + 0.1F, foundation_height / 2,
-                    base_depth / 2 + 0.1F),
-          C.stoneDark);
-
-  const float step_h = 0.015F;
-  const float step_w = 0.16F;
-  const float step_d = 0.10F;
-  const float front_z = base_depth * 0.5F + 0.12F;
-  for (int i = 0; i < 5; ++i) {
-    float const t = i / 4.0F;
-    float const x = (i % 2 == 0) ? -0.18F : 0.18F;
-    QVector3D const c = lerp(C.path, C.stone, 0.25F * (i % 2));
-    unitBox(out, unit, white, p.model,
-            QVector3D(x, -foundation_height + step_h, front_z + t * 0.55F),
-            QVector3D(step_w * (0.95F + 0.1F * (i % 2)), step_h, step_d), c);
-  }
-
-  QVector3D const skirt_color =
-      lerp(C.stoneDark, QVector3D(0.0F, 0.0F, 0.0F), 0.25F);
-  unitBox(out, unit, white, p.model, QVector3D(0.0F, 0.02F, 0.0F),
-          QVector3D(base_width * 0.50F, 0.01F, base_depth * 0.50F),
-          skirt_color);
-}
-
-inline void drawWalls(const DrawContext &p, ISubmitter &out, Mesh *,
-                      Texture *white, const BarracksPalette &C) {
-  constexpr float w = BuildingProportions::base_width;
-  constexpr float d = BuildingProportions::base_depth;
-  constexpr float h = BuildingProportions::base_height;
-
-  const float r = 0.09F;
-  const float notch = 0.07F;
-
-  const float left_x = -w * 0.5F;
-  const float right_x = w * 0.5F;
-  const float back_z = -d * 0.5F;
-  const float front_z = d * 0.5F;
-
-  const int courses = std::max(4, int(h / (2.0F * r)));
-  const float y0 = r;
-
-  auto log_x = [&](float y, float z, float x0, float x1, const QVector3D &col) {
-    draw_cylinder(out, p.model, QVector3D(x0 - notch, y, z),
-                  QVector3D(x1 + notch, y, z), r, col, white);
-  };
-  auto log_z = [&](float y, float x, float z0, float z1, const QVector3D &col) {
-    draw_cylinder(out, p.model, QVector3D(x, y, z0 - notch),
-                  QVector3D(x, y, z1 + notch), r, col, white);
-  };
-
-  const float door_w = BuildingProportions::door_width;
-  const float door_h = BuildingProportions::door_height;
-  const float gap_half = door_w * 0.5F;
-
-  for (int i = 0; i < courses; ++i) {
-    float const y = y0 + i * (2.0F * r);
-    QVector3D const log_col = lerp(C.timber, C.timberLight, (i % 2) * 0.25F);
-
-    if (y <= (door_h - 0.5F * r)) {
-      log_x(y, front_z, left_x, -gap_half, log_col);
-      log_x(y, front_z, +gap_half, right_x, log_col);
-    } else {
-      log_x(y, front_z, left_x, right_x, log_col);
-    }
-
-    log_x(y, back_z, left_x, right_x, log_col);
-    log_z(y, left_x, back_z, front_z, log_col);
-    log_z(y, right_x, back_z, front_z, log_col);
-  }
-
-  QVector3D const post_col = C.woodDark;
-  draw_cylinder(out, p.model, QVector3D(-gap_half, y0, front_z),
-                QVector3D(-gap_half, y0 + door_h, front_z), r * 0.95F, post_col,
-                white);
-  draw_cylinder(out, p.model, QVector3D(+gap_half, y0, front_z),
-                QVector3D(+gap_half, y0 + door_h, front_z), r * 0.95F, post_col,
-                white);
-  draw_cylinder(out, p.model, QVector3D(-gap_half, y0 + door_h, front_z),
-                QVector3D(+gap_half, y0 + door_h, front_z), r, C.timberLight,
-                white);
-
-  float const brace_y0 = h * 0.35F;
-  float const brace_y1 = h * 0.95F;
-  draw_cylinder(out, p.model,
-                QVector3D(left_x + 0.08F, brace_y0, back_z + 0.10F),
-                QVector3D(left_x + 0.38F, brace_y1, back_z + 0.10F), r * 0.6F,
-                C.woodDark, white);
-  draw_cylinder(out, p.model,
-                QVector3D(right_x - 0.08F, brace_y0, back_z + 0.10F),
-                QVector3D(right_x - 0.38F, brace_y1, back_z + 0.10F), r * 0.6F,
-                C.woodDark, white);
-  draw_cylinder(out, p.model,
-                QVector3D(left_x + 0.08F, brace_y0, front_z - 0.10F),
-                QVector3D(left_x + 0.38F, brace_y1, front_z - 0.10F), r * 0.6F,
-                C.woodDark, white);
-  draw_cylinder(out, p.model,
-                QVector3D(right_x - 0.08F, brace_y0, front_z - 0.10F),
-                QVector3D(right_x - 0.38F, brace_y1, front_z - 0.10F), r * 0.6F,
-                C.woodDark, white);
-}
-
-struct ChimneyInfo {
-  float x;
-  float z;
-  float base_y;
-  float topY;
-  float gapRadius;
-};
-
-inline auto drawChimney(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                        Texture *white,
-                        const BarracksPalette &C) -> ChimneyInfo {
-  constexpr float w = BuildingProportions::base_width;
-  constexpr float d = BuildingProportions::base_depth;
-  constexpr float h = BuildingProportions::base_height;
-  constexpr float rise = BuildingProportions::roof_pitch;
-
-  float const x = -w * 0.32F;
-  float const z = -d * 0.5F - 0.06F;
-
-  float const base_y = 0.18F;
-  float const ridge_y = h + rise;
-  float const top_y = ridge_y + 0.35F;
-
-  QVector3D const base_sz(BuildingProportions::chimney_width * 0.65F, 0.16F,
-                          BuildingProportions::chimney_width * 0.55F);
-  unitBox(out, unit, white, p.model, QVector3D(x, base_y + base_sz.y(), z),
-          base_sz, C.stoneDark);
-
-  int const segments = 4;
-  float const seg_h = (top_y - (base_y + base_sz.y() * 2.0F)) / float(segments);
-  float const w0 = BuildingProportions::chimney_width * 0.55F;
-  float const w1 = BuildingProportions::chimney_width * 0.34F;
-
-  for (int i = 0; i < segments; ++i) {
-    float const t = float(i) / float(segments - 1);
-    float const wy = w0 * (1.0F - t) + w1 * t;
-    float const hz = wy * 0.85F;
-    QVector3D const col =
-        (i % 2 == 0) ? C.stone : lerp(C.stone, C.stoneDark, 0.35F);
-    float const y_mid = base_y + base_sz.y() * 2.0F + seg_h * (i + 0.5F);
-    unitBox(out, unit, white, p.model, QVector3D(x, y_mid, z),
-            QVector3D(wy, seg_h * 0.5F, hz), col);
-  }
-
-  float const corbel_y = top_y - 0.14F;
-  unitBox(out, unit, white, p.model, QVector3D(x, corbel_y, z),
-          QVector3D(w1 * 1.22F, 0.025F, w1 * 1.22F), C.stoneDark);
-  unitBox(out, unit, white, p.model, QVector3D(x, corbel_y + 0.05F, z),
-          QVector3D(w1 * 1.05F, 0.02F, w1 * 1.05F),
-          lerp(C.stone, C.stoneDark, 0.2F));
-
-  float const pot_h = 0.10F;
-  unitBox(out, unit, white, p.model, QVector3D(x, top_y + pot_h * 0.5F, z),
-          QVector3D(w1 * 0.45F, pot_h * 0.5F, w1 * 0.45F),
-          lerp(C.stoneDark, QVector3D(0.08F, 0.08F, 0.08F), 0.35F));
-
-  unitBox(out, unit, white, p.model, QVector3D(x, h + rise * 0.55F, z + 0.06F),
-          QVector3D(w1 * 1.35F, 0.01F, 0.04F),
-          lerp(C.stoneDark, QVector3D(0.05F, 0.05F, 0.05F), 0.3F));
-
-  return ChimneyInfo{x, z, base_y, top_y + pot_h, 0.28F};
-}
-
-inline void drawRoofs(const DrawContext &p, ISubmitter &out, Mesh *,
-                      Texture *white, const BarracksPalette &C,
-                      const ChimneyInfo &ch) {
-  constexpr float w = BuildingProportions::base_width;
-  constexpr float d = BuildingProportions::base_depth;
-  constexpr float h = BuildingProportions::base_height;
-  constexpr float rise = BuildingProportions::roof_pitch;
-  constexpr float over = BuildingProportions::roof_overhang;
-
-  const float r = 0.085F;
-
-  const float left_x = -w * 0.5F;
-  const float right_x = w * 0.5F;
-  const float back_z = -d * 0.5F;
-  const float front_z = d * 0.5F;
-
-  const float plate_y = h;
-  const float ridge_y = h + rise;
-
-  draw_cylinder(out, p.model, QVector3D(left_x - over, plate_y, front_z + over),
-                QVector3D(right_x + over, plate_y, front_z + over), r,
-                C.woodDark, white);
-  draw_cylinder(out, p.model, QVector3D(left_x - over, plate_y, back_z - over),
-                QVector3D(right_x + over, plate_y, back_z - over), r,
-                C.woodDark, white);
-
-  draw_cylinder(out, p.model, QVector3D(left_x - over * 0.5F, ridge_y, 0.0F),
-                QVector3D(right_x + over * 0.5F, ridge_y, 0.0F), r,
-                C.timberLight, white);
-
-  const int pairs = 7;
-  for (int i = 0; i < pairs; ++i) {
-    float const t = (pairs == 1) ? 0.0F : (float(i) / float(pairs - 1));
-    float const x =
-        (left_x - over * 0.5F) * (1.0F - t) + (right_x + over * 0.5F) * t;
-
-    draw_cylinder(out, p.model, QVector3D(x, plate_y, back_z - over),
-                  QVector3D(x, ridge_y, 0.0F), r * 0.85F, C.woodDark, white);
-
-    draw_cylinder(out, p.model, QVector3D(x, plate_y, front_z + over),
-                  QVector3D(x, ridge_y, 0.0F), r * 0.85F, C.woodDark, white);
-  }
-
-  auto purlin = [&](float tz, bool front) {
-    float const z = front ? (front_z + over - tz * (front_z + over))
-                          : (back_z - over - tz * (back_z - over));
-    float const y = plate_y + tz * (ridge_y - plate_y);
-    draw_cylinder(out, p.model, QVector3D(left_x - over * 0.4F, y, z),
-                  QVector3D(right_x + over * 0.4F, y, z), r * 0.6F, C.timber,
-                  white);
-  };
-  purlin(0.35F, true);
-  purlin(0.70F, true);
-  purlin(0.35F, false);
-  purlin(0.70F, false);
-
-  auto split_thatch = [&](float y, float z, float rad, const QVector3D &col) {
-    float const gap_l = ch.x - ch.gapRadius;
-    float const gap_r = ch.x + ch.gapRadius;
-    draw_cylinder(out, p.model, QVector3D(left_x - over * 0.35F, y, z),
-                  QVector3D(gap_l, y, z), rad, col, white);
-    draw_cylinder(out, p.model, QVector3D(gap_r, y, z),
-                  QVector3D(right_x + over * 0.35F, y, z), rad, col, white);
-  };
-
-  auto thatch_row = [&](float tz, bool front, float radScale, float tint) {
-    float const z = front ? (front_z + over - tz * (front_z + over))
-                          : (back_z - over - tz * (back_z - over));
-    float const y = plate_y + tz * (ridge_y - plate_y);
-    QVector3D const col = lerp(C.thatchDark, C.thatch, clamp01(tint));
-    split_thatch(y, z, r * radScale, col);
-  };
-
-  const int rows = 9;
-  for (int i = 0; i < rows; ++i) {
-    float const tz = float(i) / float(rows - 1);
-    float const s = 1.30F - 0.6F * tz;
-    float const tint = 0.2F + 0.6F * (1.0F - tz);
-    thatch_row(tz, true, s, tint);
-    thatch_row(tz * 0.98F, false, s, tint * 0.95F);
-  }
-
-  float const eave_y = plate_y + 0.06F;
-  split_thatch(eave_y, front_z + over * 1.02F, r * 0.55F, C.thatchDark);
-  split_thatch(eave_y, back_z - over * 1.02F, r * 0.55F, C.thatchDark);
-
-  float const flash_y = plate_y + (ridge_y - plate_y) * 0.55F;
-  float const flash_zback = back_z - over * 0.20F;
-  float const ring = ch.gapRadius + 0.04F;
-  unitBox(out, nullptr, white, p.model, QVector3D(ch.x, flash_y, flash_zback),
-          QVector3D(ring, 0.008F, 0.02F), C.stoneDark);
-}
-
-inline void drawDoor(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                     Texture *white, const BarracksPalette &C) {
-  constexpr float d = BuildingProportions::base_depth;
-  constexpr float d_w = BuildingProportions::door_width;
-  constexpr float d_h = BuildingProportions::door_height;
-
-  const float y0 = 0.09F;
-  const float zf = d * 0.5F;
-
-  QVector3D const frame_col = C.woodDark;
-  unitBox(out, unit, white, p.model,
-          QVector3D(0.0F, y0 + d_h * 0.5F, zf + 0.015F),
-          QVector3D(d_w * 0.5F, d_h * 0.5F, 0.02F), C.door);
-
-  float const plank_w = d_w / 6.0F;
-  for (int i = 0; i < 6; ++i) {
-    float const cx = -d_w * 0.5F + plank_w * (i + 0.5F);
-    QVector3D const plank_col = lerp(C.door, C.woodDark, 0.15F * (i % 2));
-    unitBox(out, unit, white, p.model,
-            QVector3D(cx, y0 + d_h * 0.5F, zf + 0.022F),
-            QVector3D(plank_w * 0.48F, d_h * 0.48F, 0.006F), plank_col);
-  }
-
-  draw_cylinder(out, p.model,
-                QVector3D(-d_w * 0.45F, y0 + d_h * 0.35F, zf + 0.03F),
-                QVector3D(+d_w * 0.45F, y0 + d_h * 0.35F, zf + 0.03F), 0.02F,
-                frame_col, white);
-
-  draw_cylinder(out, p.model,
-                QVector3D(d_w * 0.32F, y0 + d_h * 0.45F, zf + 0.04F),
-                QVector3D(d_w * 0.42F, y0 + d_h * 0.45F, zf + 0.04F), 0.012F,
-                C.timberLight, white);
-
-  unitBox(out, unit, white, p.model,
-          QVector3D(0.0F, y0 + d_h + 0.10F, zf + 0.02F),
-          QVector3D(0.22F, 0.06F, 0.01F), C.woodDark);
-  unitBox(out, unit, white, p.model,
-          QVector3D(0.0F, y0 + d_h + 0.10F, zf + 0.025F),
-          QVector3D(0.18F, 0.05F, 0.008F), C.team);
-  unitBox(out, unit, white, p.model,
-          QVector3D(0.0F, y0 + d_h + 0.10F, zf + 0.03F),
-          QVector3D(0.08F, 0.02F, 0.007F), C.teamTrim);
-}
-
-inline void drawWindows(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                        Texture *white, const BarracksPalette &C) {
-  constexpr float base_w = BuildingProportions::base_width;
-  constexpr float base_d = BuildingProportions::base_depth;
-  constexpr float base_h = BuildingProportions::base_height;
-
-  const float left_x = -base_w * 0.5F;
-  const float right_x = base_w * 0.5F;
-  const float back_z = -base_d * 0.5F;
-  const float front_z = base_d * 0.5F;
-
-  float const window_w = BuildingProportions::window_width * 0.55F;
-  float const window_h = BuildingProportions::window_height * 0.55F;
-  float const frame_t = 0.03F;
-
-  auto framed_window = [&](QVector3D center, bool shutters) {
-    unitBox(out, unit, white, p.model, center + QVector3D(0, 0, 0.012F),
-            QVector3D(window_w * 0.5F, window_h * 0.5F, 0.008F), C.window);
-
-    unitBox(out, unit, white, p.model, center + QVector3D(0, 0, 0.016F),
-            QVector3D(window_w * 0.5F, frame_t, 0.006F), C.timber);
-    unitBox(out, unit, white, p.model, center + QVector3D(0, 0, 0.016F),
-            QVector3D(frame_t, window_h * 0.5F, 0.006F), C.timber);
-
-    unitBox(out, unit, white, p.model, center + QVector3D(0, 0, 0.02F),
-            QVector3D(window_w * 0.02F, window_h * 0.48F, 0.004F),
-            C.timberLight);
-    unitBox(out, unit, white, p.model, center + QVector3D(0, 0, 0.02F),
-            QVector3D(window_w * 0.48F, window_h * 0.02F, 0.004F),
-            C.timberLight);
-
-    if (shutters) {
-      unitBox(out, unit, white, p.model,
-              center + QVector3D(-window_w * 0.65F, 0, 0.018F),
-              QVector3D(window_w * 0.30F, window_h * 0.55F, 0.004F),
-              C.woodDark);
-      unitBox(out, unit, white, p.model,
-              center + QVector3D(+window_w * 0.65F, 0, 0.018F),
-              QVector3D(window_w * 0.30F, window_h * 0.55F, 0.004F),
-              C.woodDark);
-    }
-  };
-
-  framed_window(QVector3D(-0.65F, 0.95F, front_z + 0.01F), true);
-  framed_window(QVector3D(+0.65F, 0.95F, front_z + 0.01F), true);
-  framed_window(QVector3D(0.0F, 1.00F, back_z - 0.01F), true);
-
-  framed_window(QVector3D(left_x + 0.06F, 0.85F, 0.0F), false);
-  framed_window(QVector3D(right_x - 0.06F, 0.85F, 0.0F), false);
-}
-
-inline void drawAnnex(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                      Texture *white, const BarracksPalette &C) {
-  constexpr float base_w = BuildingProportions::base_width;
-  constexpr float base_d = BuildingProportions::base_depth;
-  constexpr float annex_h = BuildingProportions::annex_height;
-  constexpr float annex_w = BuildingProportions::annex_width;
-  constexpr float annex_d = BuildingProportions::annex_depth;
-
-  float const x = base_w * 0.5F + annex_w * 0.5F - 0.05F;
-  float const z = 0.05F;
-
-  unitBox(out, unit, white, p.model, QVector3D(x, annex_h * 0.5F, z),
-          QVector3D(annex_w * 0.5F, annex_h * 0.5F, annex_d * 0.5F),
-          C.plasterShade);
-
-  unitBox(out, unit, white, p.model, QVector3D(x, annex_h + 0.02F, z),
-          QVector3D(annex_w * 0.55F, 0.02F, annex_d * 0.55F), C.woodDark);
-
-  float const plate_y = annex_h;
-  float const front_z = z + annex_d * 0.5F;
-  float const back_z = z - annex_d * 0.5F;
-  draw_cylinder(out, p.model,
-                QVector3D(x - annex_w * 0.52F, plate_y, back_z - 0.12F),
-                QVector3D(x + annex_w * 0.52F, plate_y, back_z - 0.12F), 0.05F,
-                C.woodDark, white);
-
-  float const ridge_y = annex_h + BuildingProportions::annex_roof_height;
-  draw_cylinder(out, p.model,
-                QVector3D(x - annex_w * 0.50F, ridge_y, back_z - 0.02F),
-                QVector3D(x + annex_w * 0.50F, ridge_y, back_z - 0.02F), 0.05F,
-                C.timberLight, white);
-
-  int const rows = 6;
-  for (int i = 0; i < rows; ++i) {
-    float const t = float(i) / float(rows - 1);
-    float const y = plate_y + t * (ridge_y - plate_y);
-    float const zrow = back_z - 0.02F - 0.10F * (1.0F - t);
-    QVector3D const col =
-        lerp(C.thatchDark, C.thatch, 0.5F + 0.4F * (1.0F - t));
-    draw_cylinder(out, p.model, QVector3D(x - annex_w * 0.55F, y, zrow),
-                  QVector3D(x + annex_w * 0.55F, y, zrow),
-                  0.06F * (1.15F - 0.6F * t), col, white);
-  }
-
-  unitBox(out, unit, white, p.model,
-          QVector3D(x + annex_w * 0.01F, 0.55F, front_z + 0.01F),
-          QVector3D(0.20F, 0.18F, 0.01F), C.door);
-}
-
-inline void drawProps(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                      Texture *white, const BarracksPalette &C) {
-  unitBox(out, unit, white, p.model, QVector3D(0.85F, 0.10F, 0.90F),
-          QVector3D(0.16F, 0.10F, 0.16F), C.crate);
-  unitBox(out, unit, white, p.model, QVector3D(0.85F, 0.22F, 0.90F),
-          QVector3D(0.12F, 0.02F, 0.12F), C.woodDark);
-
-  unitBox(out, unit, white, p.model, QVector3D(-0.9F, 0.12F, -0.80F),
-          QVector3D(0.12F, 0.10F, 0.12F), C.crate);
-  unitBox(out, unit, white, p.model, QVector3D(-0.9F, 0.20F, -0.80F),
-          QVector3D(0.13F, 0.02F, 0.13F), C.woodDark);
-}
-
-inline void drawBannerAndPole(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                              Texture *white, const BarracksPalette &C) {
-  constexpr float base_width = BuildingProportions::base_width;
-  constexpr float base_depth = BuildingProportions::base_depth;
-  constexpr float banner_pole_height = BuildingProportions::banner_pole_height;
-  constexpr float banner_pole_radius = BuildingProportions::banner_pole_radius;
-  constexpr float banner_width = BuildingProportions::banner_width;
-  constexpr float banner_height = BuildingProportions::banner_height;
-
-  float const pole_x = -base_width / 2 - 0.65F;
-  float const pole_z = base_depth / 2 - 0.2F;
-
-  float const pole_height = banner_pole_height * 1.9F;
-  float const pole_radius = banner_pole_radius * 1.3F;
-  float const bw = banner_width * 1.8F;
-  float const bh = banner_height * 1.8F;
-
-  QVector3D const pole_center(pole_x, pole_height / 2.0F, pole_z);
-  QVector3D const pole_size(pole_radius * 1.6F, pole_height / 2.0F,
-                            pole_radius * 1.6F);
-  unitBox(out, unit, white, p.model, pole_center, pole_size, C.woodDark);
-
-  float const target_width = bw * 1.25F;
-  float const target_height = bh * 0.75F;
-  float const panel_depth = 0.02F;
-
-  float const beam_length = target_width * 0.45F;
-  float const max_lowering = pole_height * 0.85F;
-
-  auto captureColors = BarracksFlagRenderer::get_capture_colors(
-      p, C.team, C.teamTrim, max_lowering);
-
-  float beam_y =
-      pole_height - target_height * 0.25F - captureColors.loweringOffset;
-  float flag_y =
-      pole_height - target_height / 2.0F - captureColors.loweringOffset;
-
-  QVector3D const beam_start(pole_x + 0.02F, beam_y, pole_z);
-  QVector3D const beam_end(pole_x + beam_length + 0.02F, beam_y, pole_z);
-  draw_cylinder(out, p.model, beam_start, beam_end, pole_radius * 0.35F,
-                C.timber, white);
-
-  QVector3D const connector_top(
-      beam_end.x(), beam_end.y() - target_height * 0.35F, beam_end.z());
-  draw_cylinder(out, p.model, beam_end, connector_top, pole_radius * 0.18F,
-                C.timberLight, white);
-
-  float const panel_x = beam_end.x() + (target_width * 0.5F - beam_length);
-  unitBox(out, unit, white, p.model, QVector3D(panel_x, flag_y, pole_z + 0.01F),
-          QVector3D(target_width / 2.0F, target_height / 2.0F, panel_depth),
-          captureColors.teamColor);
-
-  unitBox(
-      out, unit, white, p.model,
-      QVector3D(panel_x, flag_y - target_height / 2.0F + 0.04F, pole_z + 0.01F),
-      QVector3D(target_width / 2.0F + 0.02F, 0.04F, 0.015F),
-      captureColors.teamTrimColor);
-  unitBox(
-      out, unit, white, p.model,
-      QVector3D(panel_x, flag_y + target_height / 2.0F - 0.04F, pole_z + 0.01F),
-      QVector3D(target_width / 2.0F + 0.02F, 0.04F, 0.015F),
-      captureColors.teamTrimColor);
-}
-
-inline void draw_rally_flag_if_any(const DrawContext &p, ISubmitter &out,
-                                   Texture *white, const BarracksPalette &C) {
-  BarracksFlagRenderer::FlagColors colors{.team = C.team,
-                                          .teamTrim = C.teamTrim,
-                                          .timber = C.timber,
-                                          .timberLight = C.timberLight,
-                                          .woodDark = C.woodDark};
-  BarracksFlagRenderer::draw_rally_flag_if_any(p, out, white, colors);
-}
-
-inline void draw_health_bar(const DrawContext &p, ISubmitter &out, Mesh *unit,
-                            Texture *white) {
-  if (p.entity == nullptr) {
-    return;
-  }
-  auto *u = p.entity->getComponent<Engine::Core::UnitComponent>();
-  if (u == nullptr) {
-    return;
-  }
-
-  int const mh = std::max(1, u->max_health);
-  float const ratio = std::clamp(u->health / float(mh), 0.0F, 1.0F);
-  if (ratio <= 0.0F) {
-    return;
-  }
-
-  constexpr float base_height = BuildingProportions::base_height;
-  constexpr float roof_pitch = BuildingProportions::roof_pitch;
-  float const roof_peak = base_height + roof_pitch;
-  float const bar_y = roof_peak + 0.12F;
-
-  constexpr float bar_width = BuildingProportions::base_width * 0.9F;
-  constexpr float bar_height = 0.08F;
-  constexpr float bar_depth = 0.12F;
-
-  QVector3D const bg_color(0.06F, 0.06F, 0.06F);
-  unitBox(out, unit, white, p.model, QVector3D(0.0F, bar_y, 0.0F),
-          QVector3D(bar_width / 2.0F, bar_height / 2.0F, bar_depth / 2.0F),
-          bg_color);
-
-  float const fill_width = bar_width * ratio;
-  float const fill_x = -(bar_width - fill_width) * 0.5F;
-
-  QVector3D const red(0.85F, 0.15F, 0.15F);
-  QVector3D const green(0.22F, 0.78F, 0.22F);
-  QVector3D const fg_color = green * ratio + red * (1.0F - ratio);
-
-  unitBox(out, unit, white, p.model, QVector3D(fill_x, bar_y + 0.005F, 0.0F),
-          QVector3D(fill_width / 2.0F, (bar_height / 2.0F) * 0.9F,
-                    (bar_depth / 2.0F) * 0.95F),
-          fg_color);
-}
-
-inline void draw_selectionFX(const DrawContext &p, ISubmitter &out) {
-  QMatrix4x4 m;
-  QVector3D const pos = p.model.column(3).toVector3D();
-  m.translate(pos.x(), 0.0F, pos.z());
-  m.scale(2.2F, 1.0F, 2.0F);
-  if (p.selected) {
-    out.selectionSmoke(m, QVector3D(0.2F, 0.85F, 0.2F), 0.35F);
-  } else if (p.hovered) {
-    out.selectionSmoke(m, QVector3D(0.95F, 0.92F, 0.25F), 0.22F);
-  }
-}
-
-void draw_barracks(const DrawContext &p, ISubmitter &out) {
-  if ((p.resources == nullptr) || (p.entity == nullptr)) {
-    return;
-  }
-
-  auto *t = p.entity->getComponent<Engine::Core::TransformComponent>();
-  auto *r = p.entity->getComponent<Engine::Core::RenderableComponent>();
-  if ((t == nullptr) || (r == nullptr)) {
-    return;
-  }
-
-  Mesh *unit = p.resources->unit();
-  Texture *white = p.resources->white();
-
-  QVector3D const team(r->color[0], r->color[1], r->color[2]);
-  BarracksPalette const c = make_palette(team);
-
-  drawFoundation(p, out, unit, white, c);
-  drawAnnex(p, out, unit, white, c);
-  drawWalls(p, out, unit, white, c);
-  ChimneyInfo const ch = drawChimney(p, out, unit, white, c);
-  drawRoofs(p, out, unit, white, c, ch);
-  drawDoor(p, out, unit, white, c);
-  drawWindows(p, out, unit, white, c);
-  drawBannerAndPole(p, out, unit, white, c);
-  drawProps(p, out, unit, white, c);
-
-  draw_rally_flag_if_any(p, out, white, c);
-  draw_health_bar(p, out, unit, white);
-  draw_selectionFX(p, out);
-}
-
-} // namespace
-
-void register_barracks_renderer(Render::GL::EntityRendererRegistry &registry) {
-  registry.register_renderer("barracks_kingdom", draw_barracks);
-}
-
-} // namespace Render::GL::Kingdom

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

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

+ 0 - 215
render/entity/nations/kingdom/healer_renderer.cpp

@@ -1,215 +0,0 @@
-#include "healer_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 "healer_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>
-
-namespace Render::GL::Kingdom {
-
-namespace {
-
-constexpr std::string_view k_default_style_key = "default";
-
-auto style_registry() -> std::unordered_map<std::string, HealerStyleConfig> & {
-  static std::unordered_map<std::string, HealerStyleConfig> styles;
-  return styles;
-}
-
-void ensure_healer_styles_registered() {
-  static const bool registered = []() {
-    register_kingdom_healer_style();
-    return true;
-  }();
-  (void)registered;
-}
-
-constexpr float k_team_mix_weight = 0.65F;
-constexpr float k_style_mix_weight = 0.35F;
-
-} // namespace
-
-void register_healer_style(const std::string &nation_id,
-                           const HealerStyleConfig &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 HealerRenderer : public HumanoidRendererBase {
-public:
-  auto get_proportion_scaling() const -> QVector3D override {
-    return {0.92F, 1.00F, 0.94F};
-  }
-
-  void get_variant(const DrawContext &ctx, uint32_t seed,
-                   HumanoidVariant &v) const override {
-    QVector3D const team_tint = resolveTeamTint(ctx);
-    v.palette = makeHumanoidPalette(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_height_jitter = (hash_01(seed ^ 0xABCDU) - 0.5F) * 0.03F;
-    float const arm_asymmetry = (hash_01(seed ^ 0xDEF0U) - 0.5F) * 0.04F;
-
-    QVector3D const idle_hand_l(-0.10F + arm_asymmetry,
-                                HP::SHOULDER_Y + 0.10F + arm_height_jitter,
-                                0.45F);
-    QVector3D const idle_hand_r(
-        0.10F - arm_asymmetry * 0.5F,
-        HP::SHOULDER_Y + 0.10F + arm_height_jitter * 0.8F, 0.45F);
-
-    controller.placeHandAt(true, idle_hand_l);
-    controller.placeHandAt(false, idle_hand_r);
-  }
-
-  void addAttachments(const DrawContext &ctx, const HumanoidVariant &v,
-                      const HumanoidPose &pose,
-                      const HumanoidAnimationContext &anim_ctx,
-                      ISubmitter &out) const override {}
-
-  void draw_helmet(const DrawContext &ctx, const HumanoidVariant &v,
-                   const HumanoidPose &pose, ISubmitter &out) const override {
-    auto &registry = EquipmentRegistry::instance();
-    auto helmet = registry.get(EquipmentCategory::Helmet, "kingdom_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 {
-    if (resolve_style(ctx).show_armor) {
-      auto &registry = EquipmentRegistry::instance();
-      auto armor =
-          registry.get(EquipmentCategory::Armor, "kingdom_light_armor");
-      if (armor) {
-        armor->render(ctx, pose.body_frames, v.palette, anim, out);
-      }
-    }
-  }
-
-private:
-  auto
-  resolve_style(const DrawContext &ctx) const -> const HealerStyleConfig & {
-    ensure_healer_styles_registered();
-    auto &styles = style_registry();
-    std::string nation_id;
-    if (ctx.entity != nullptr) {
-      if (auto *unit =
-              ctx.entity->getComponent<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 HealerStyleConfig default_style{};
-    return default_style;
-  }
-
-public:
-  auto resolve_shader_key(const DrawContext &ctx) const -> QString {
-    const HealerStyleConfig &style = resolve_style(ctx);
-    if (!style.shader_id.empty()) {
-      return QString::fromStdString(style.shader_id);
-    }
-    return QStringLiteral("healer");
-  }
-
-private:
-  void apply_palette_overrides(const HealerStyleConfig &style,
-                               const QVector3D &team_tint,
-                               HumanoidVariant &variant) const {
-    auto apply_color = [&](const std::optional<QVector3D> &override_color,
-                           QVector3D &target) {
-      target = mix_palette_color(target, override_color, team_tint,
-                                 k_team_mix_weight, k_style_mix_weight);
-    };
-
-    apply_color(style.cloth_color, variant.palette.cloth);
-    apply_color(style.leather_color, variant.palette.leather);
-    apply_color(style.leather_dark_color, variant.palette.leatherDark);
-    apply_color(style.metal_color, variant.palette.metal);
-    apply_color(style.wood_color, variant.palette.wood);
-  }
-};
-
-void registerHealerRenderer(Render::GL::EntityRendererRegistry &registry) {
-  ensure_healer_styles_registered();
-  static HealerRenderer const renderer;
-  registry.register_renderer(
-      "troops/kingdom/healer", [](const DrawContext &ctx, ISubmitter &out) {
-        static HealerRenderer const static_renderer;
-        Shader *healer_shader = nullptr;
-        if (ctx.backend != nullptr) {
-          QString shader_key = static_renderer.resolve_shader_key(ctx);
-          healer_shader = ctx.backend->shader(shader_key);
-          if (healer_shader == nullptr) {
-            healer_shader = ctx.backend->shader(QStringLiteral("healer"));
-          }
-        }
-        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
-        if ((scene_renderer != nullptr) && (healer_shader != nullptr)) {
-          scene_renderer->setCurrentShader(healer_shader);
-        }
-        static_renderer.render(ctx, out);
-        if (scene_renderer != nullptr) {
-          scene_renderer->setCurrentShader(nullptr);
-        }
-      });
-}
-
-} // namespace Render::GL::Kingdom

+ 0 - 15
render/entity/nations/kingdom/healer_renderer.h

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

+ 0 - 31
render/entity/nations/kingdom/healer_style.cpp

@@ -1,31 +0,0 @@
-#include "healer_style.h"
-#include "healer_renderer.h"
-
-#include <QVector3D>
-
-namespace {
-constexpr QVector3D k_kingdom_cloth{0.85F, 0.92F, 0.88F};
-constexpr QVector3D k_kingdom_leather{0.32F, 0.23F, 0.16F};
-constexpr QVector3D k_kingdom_leather_dark{0.18F, 0.16F, 0.14F};
-constexpr QVector3D k_kingdom_metal{0.70F, 0.70F, 0.72F};
-constexpr QVector3D k_kingdom_wood{0.40F, 0.30F, 0.18F};
-constexpr QVector3D k_kingdom_cape{0.25F, 0.85F, 0.40F};
-} // namespace
-
-namespace Render::GL::Kingdom {
-
-void register_kingdom_healer_style() {
-  HealerStyleConfig style;
-  style.cloth_color = k_kingdom_cloth;
-  style.leather_color = k_kingdom_leather;
-  style.leather_dark_color = k_kingdom_leather_dark;
-  style.metal_color = k_kingdom_metal;
-  style.wood_color = k_kingdom_wood;
-  style.cape_color = k_kingdom_cape;
-  style.shader_id = "healer_kingdom_of_iron";
-
-  register_healer_style("default", style);
-  register_healer_style("kingdom_of_iron", style);
-}
-
-} // namespace Render::GL::Kingdom

+ 0 - 27
render/entity/nations/kingdom/healer_style.h

@@ -1,27 +0,0 @@
-#pragma once
-
-#include <QVector3D>
-#include <optional>
-#include <string>
-
-namespace Render::GL::Kingdom {
-
-struct HealerStyleConfig {
-  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> cape_color;
-
-  bool show_helmet = true;
-  bool show_armor = true;
-  bool show_cape = true;
-
-  std::string attachment_profile;
-  std::string shader_id;
-};
-
-void register_kingdom_healer_style();
-
-} // namespace Render::GL::Kingdom

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

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

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

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

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

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

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

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

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

@@ -1,58 +0,0 @@
-#include "horse_swordsman_renderer.h"
-
-#include "../../../equipment/horse/saddles/light_cavalry_saddle_renderer.h"
-#include "../../../equipment/horse/tack/reins_renderer.h"
-#include "../../../gl/backend.h"
-#include "../../../gl/shader.h"
-#include "../../../scene_renderer.h"
-#include "../../../submitter.h"
-#include "../../mounted_knight_renderer_base.h"
-
-#include <memory>
-
-namespace Render::GL::Kingdom {
-namespace {
-
-auto makeMountedKnightConfig() -> MountedKnightRendererConfig {
-  MountedKnightRendererConfig config;
-  config.sword_equipment_id = "sword_kingdom";
-  config.shield_equipment_id = "shield_kingdom";
-  config.helmet_equipment_id = "kingdom_heavy";
-  config.armor_equipment_id = "kingdom_heavy_armor";
-  config.helmet_offset_moving = 0.03F;
-  config.horse_attachments.emplace_back(
-      std::make_shared<LightCavalrySaddleRenderer>());
-  config.horse_attachments.emplace_back(std::make_shared<ReinsRenderer>());
-  return config;
-}
-
-} // namespace
-
-void registerMountedKnightRenderer(EntityRendererRegistry &registry) {
-  registry.register_renderer(
-      "troops/kingdom/horse_swordsman",
-      [](const DrawContext &ctx, ISubmitter &out) {
-        static MountedKnightRendererBase const static_renderer(
-            makeMountedKnightConfig());
-        Shader *horse_swordsman_shader = nullptr;
-        if (ctx.backend != nullptr) {
-          QString shader_key = static_renderer.resolve_shader_key(ctx);
-          horse_swordsman_shader = ctx.backend->shader(shader_key);
-          if (horse_swordsman_shader == nullptr) {
-            horse_swordsman_shader =
-                ctx.backend->shader(QStringLiteral("horse_swordsman"));
-          }
-        }
-        auto *scene_renderer = dynamic_cast<Renderer *>(&out);
-        if ((scene_renderer != nullptr) &&
-            (horse_swordsman_shader != nullptr)) {
-          scene_renderer->setCurrentShader(horse_swordsman_shader);
-        }
-        static_renderer.render(ctx, out);
-        if (scene_renderer != nullptr) {
-          scene_renderer->setCurrentShader(nullptr);
-        }
-      });
-}
-
-} // namespace Render::GL::Kingdom

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

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

+ 0 - 16
render/entity/nations/kingdom/horse_swordsman_style.cpp

@@ -1,16 +0,0 @@
-#include "horse_swordsman_style.h"
-#include <unordered_map>
-
-static std::unordered_map<std::string, HorseSwordsmanStyleConfig> styles;
-
-void register_kingdom_horse_swordsman_style() {
-  HorseSwordsmanStyleConfig default_style;
-  default_style.cloth_color = QVector3D(0.7F, 0.7F, 0.9F);
-  default_style.leather_color = QVector3D(0.4F, 0.3F, 0.2F);
-  default_style.metal_color = QVector3D(0.8F, 0.8F, 0.7F);
-  default_style.shader_id = "horse_swordsman_kingdom_of_iron";
-  default_style.show_helmet = true;
-  default_style.show_armor = true;
-  default_style.has_cavalry_shield = true;
-  styles["default"] = default_style;
-}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików