Browse Source

Merge branch 'main' into copilot/add-homes-for-barracks

Adam Djellouli 2 months ago
parent
commit
98e26fa60c

+ 117 - 14
app/controllers/command_controller.cpp

@@ -13,6 +13,8 @@
 #include "game/game_config.h"
 #include "units/spawn_type.h"
 #include <QPointF>
+#include <cmath>
+#include <numbers>
 #include <qglobal.h>
 #include <qobject.h>
 #include <qtmetamacros.h>
@@ -669,12 +671,22 @@ auto CommandController::on_formation_command() -> CommandResult {
     QVector3D center(0.0F, 0.0F, 0.0F);
     int valid_count = 0;
 
+    m_formation_units.clear();
+
     for (auto id : selected) {
       auto *entity = m_world->get_entity(id);
       if (entity == nullptr) {
         continue;
       }
 
+      auto *unit = entity->get_component<Engine::Core::UnitComponent>();
+      if (unit == nullptr ||
+          unit->spawn_type == Game::Units::SpawnType::Barracks) {
+        continue;
+      }
+
+      m_formation_units.push_back(id);
+
       auto *transform =
           entity->get_component<Engine::Core::TransformComponent>();
       if (transform != nullptr) {
@@ -690,25 +702,18 @@ auto CommandController::on_formation_command() -> CommandResult {
       center.setY(center.y() / static_cast<float>(valid_count));
       center.setZ(center.z() / static_cast<float>(valid_count));
 
-      auto targets =
-          Game::Systems::FormationPlanner::spread_formation_by_nation(
-              *m_world, selected, center,
-              Game::GameConfig::instance()
-                  .gameplay()
-                  .formation_spacing_default);
+      m_is_placing_formation = true;
+      m_formation_placement_position = center;
+      m_formation_placement_angle = 0.0F;
 
-      Game::Systems::CommandService::MoveOptions opts;
-      opts.group_move = selected.size() > 1;
-      opts.clear_attack_intent = true;
-      Game::Systems::CommandService::moveUnits(*m_world, selected, targets,
-                                               opts);
+      emit formation_placement_started();
+      emit formation_placement_updated(m_formation_placement_position,
+                                       m_formation_placement_angle);
     }
   }
 
-  emit formation_mode_changed(should_enable_formation);
-
   result.input_consumed = true;
-  result.reset_cursor_to_normal = true;
+  result.reset_cursor_to_normal = false;
   return result;
 }
 
@@ -734,6 +739,104 @@ bool CommandController::any_selected_in_formation_mode() const {
   return false;
 }
 
+void CommandController::update_formation_placement(const QVector3D &position) {
+  if (!m_is_placing_formation) {
+    return;
+  }
+  m_formation_placement_position = position;
+  emit formation_placement_updated(m_formation_placement_position,
+                                   m_formation_placement_angle);
+}
+
+void CommandController::update_formation_rotation(float angle_degrees) {
+  if (!m_is_placing_formation) {
+    return;
+  }
+  m_formation_placement_angle = angle_degrees;
+  emit formation_placement_updated(m_formation_placement_position,
+                                   m_formation_placement_angle);
+}
+
+void CommandController::confirm_formation_placement() {
+  if (!m_is_placing_formation || m_formation_units.empty()) {
+    cancel_formation_placement();
+    return;
+  }
+
+  auto formation_result =
+      Game::Systems::FormationPlanner::get_formation_with_facing(
+          *m_world, m_formation_units, m_formation_placement_position,
+          Game::GameConfig::instance().gameplay().formation_spacing_default);
+
+  float const angle_rad =
+      m_formation_placement_angle * std::numbers::pi_v<float> / 180.0F;
+  float const cos_a = std::cos(angle_rad);
+  float const sin_a = std::sin(angle_rad);
+
+  for (size_t i = 0; i < m_formation_units.size(); ++i) {
+
+    if (i < formation_result.positions.size()) {
+      QVector3D &pos = formation_result.positions[i];
+      float const dx = pos.x() - m_formation_placement_position.x();
+      float const dz = pos.z() - m_formation_placement_position.z();
+
+      float const rotated_x = dx * cos_a - dz * sin_a;
+      float const rotated_z = dx * sin_a + dz * cos_a;
+
+      pos.setX(m_formation_placement_position.x() + rotated_x);
+      pos.setZ(m_formation_placement_position.z() + rotated_z);
+    }
+
+    auto *entity = m_world->get_entity(m_formation_units[i]);
+    if (entity == nullptr) {
+      continue;
+    }
+    auto *transform = entity->get_component<Engine::Core::TransformComponent>();
+    if (transform != nullptr) {
+
+      float unit_facing = (i < formation_result.facing_angles.size())
+                              ? formation_result.facing_angles[i]
+                              : 0.0F;
+      transform->desired_yaw = unit_facing + m_formation_placement_angle;
+      transform->has_desired_yaw = true;
+    }
+  }
+
+  Game::Systems::CommandService::MoveOptions opts;
+  opts.group_move = m_formation_units.size() > 1;
+  opts.clear_attack_intent = true;
+  Game::Systems::CommandService::moveUnits(*m_world, m_formation_units,
+                                           formation_result.positions, opts);
+
+  m_is_placing_formation = false;
+  m_formation_units.clear();
+  emit formation_placement_ended();
+  emit formation_mode_changed(true);
+}
+
+void CommandController::cancel_formation_placement() {
+  if (!m_is_placing_formation) {
+    return;
+  }
+
+  for (auto id : m_formation_units) {
+    auto *entity = m_world->get_entity(id);
+    if (entity == nullptr) {
+      continue;
+    }
+    auto *formation_mode =
+        entity->get_component<Engine::Core::FormationModeComponent>();
+    if (formation_mode != nullptr) {
+      formation_mode->active = false;
+    }
+  }
+
+  m_is_placing_formation = false;
+  m_formation_units.clear();
+  emit formation_placement_ended();
+  emit formation_mode_changed(false);
+}
+
 auto CommandController::on_run_command() -> CommandResult {
   CommandResult result;
   if (m_selection_system == nullptr || m_world == nullptr) {

+ 22 - 0
app/controllers/command_controller.h

@@ -57,6 +57,20 @@ public:
   }
   void clear_patrol_first_waypoint() { m_has_patrol_first_waypoint = false; }
 
+  [[nodiscard]] bool is_placing_formation() const {
+    return m_is_placing_formation;
+  }
+  void update_formation_placement(const QVector3D &position);
+  void update_formation_rotation(float angle_degrees);
+  void confirm_formation_placement();
+  void cancel_formation_placement();
+  [[nodiscard]] QVector3D get_formation_placement_position() const {
+    return m_formation_placement_position;
+  }
+  [[nodiscard]] float get_formation_placement_angle() const {
+    return m_formation_placement_angle;
+  }
+
   Q_INVOKABLE [[nodiscard]] bool any_selected_in_hold_mode() const;
   Q_INVOKABLE [[nodiscard]] bool any_selected_in_guard_mode() const;
   Q_INVOKABLE [[nodiscard]] bool any_selected_in_formation_mode() const;
@@ -69,6 +83,9 @@ signals:
   void guard_mode_changed(bool active);
   void formation_mode_changed(bool active);
   void run_mode_changed(bool active);
+  void formation_placement_started();
+  void formation_placement_updated(QVector3D position, float angle);
+  void formation_placement_ended();
 
 private:
   Engine::Core::World *m_world;
@@ -78,6 +95,11 @@ private:
   bool m_has_patrol_first_waypoint = false;
   QVector3D m_patrol_first_waypoint;
 
+  bool m_is_placing_formation = false;
+  QVector3D m_formation_placement_position;
+  float m_formation_placement_angle = 0.0F;
+  std::vector<Engine::Core::EntityID> m_formation_units;
+
   static void reset_movement(Engine::Core::Entity *entity);
 };
 

+ 59 - 0
app/core/game_engine.cpp

@@ -114,6 +114,7 @@
 #include "render/entity/healer_aura_renderer.h"
 #include "render/entity/healing_beam_renderer.h"
 #include "render/geom/arrow.h"
+#include "render/geom/formation_arrow.h"
 #include "render/geom/patrol_flags.h"
 #include "render/geom/stone.h"
 #include "render/gl/bootstrap.h"
@@ -318,6 +319,12 @@ GameEngine::GameEngine(QObject *parent)
   connect(m_commandController.get(),
           &App::Controllers::CommandController::formation_mode_changed, this,
           &GameEngine::formation_mode_changed);
+  connect(m_commandController.get(),
+          &App::Controllers::CommandController::formation_placement_started,
+          [this]() { emit placing_formation_changed(); });
+  connect(m_commandController.get(),
+          &App::Controllers::CommandController::formation_placement_ended,
+          [this]() { emit placing_formation_changed(); });
 
   connect(this, SIGNAL(selected_units_changed()), m_selectedUnitsModel,
           SLOT(refresh()));
@@ -518,6 +525,45 @@ auto GameEngine::any_selected_in_run_mode() const -> bool {
   return m_input_handler->any_selected_in_run_mode();
 }
 
+auto GameEngine::is_placing_formation() const -> bool {
+  if (m_commandController) {
+    return m_commandController->is_placing_formation();
+  }
+  return false;
+}
+
+void GameEngine::on_formation_mouse_move(qreal sx, qreal sy) {
+  if (!m_input_handler) {
+    return;
+  }
+  ensure_initialized();
+  m_input_handler->on_formation_mouse_move(sx, sy, m_viewport);
+}
+
+void GameEngine::on_formation_scroll(float delta) {
+  if (!m_input_handler) {
+    return;
+  }
+  ensure_initialized();
+  m_input_handler->on_formation_scroll(delta);
+}
+
+void GameEngine::on_formation_confirm() {
+  if (!m_input_handler) {
+    return;
+  }
+  ensure_initialized();
+  m_input_handler->on_formation_confirm();
+}
+
+void GameEngine::on_formation_cancel() {
+  if (!m_input_handler) {
+    return;
+  }
+  ensure_initialized();
+  m_input_handler->on_formation_cancel();
+}
+
 void GameEngine::on_patrol_click(qreal sx, qreal sy) {
   if (!m_input_handler || !m_camera) {
     return;
@@ -833,6 +879,19 @@ void GameEngine::render(int pixelWidth, int pixelHeight) {
     Render::GL::renderPatrolFlags(m_renderer.get(), res, *m_world,
                                   preview_waypoint);
   }
+
+  if (auto *res = m_renderer->resources()) {
+    if (m_commandController && m_commandController->is_placing_formation()) {
+      Render::GL::FormationPlacementInfo placement;
+      placement.position =
+          m_commandController->get_formation_placement_position();
+      placement.angle_degrees =
+          m_commandController->get_formation_placement_angle();
+      placement.active = true;
+      Render::GL::renderFormationArrow(m_renderer.get(), res, placement);
+    }
+  }
+
   m_renderer->end_frame();
 
   if (m_loading_overlay_wait_for_first_frame) {

+ 8 - 0
app/core/game_engine.h

@@ -155,6 +155,8 @@ public:
                  loading_progress_changed)
   Q_PROPERTY(QString loading_stage_text READ loading_stage_text NOTIFY
                  loading_stage_changed)
+  Q_PROPERTY(bool is_placing_formation READ is_placing_formation NOTIFY
+                 placing_formation_changed)
 
   Q_INVOKABLE void on_map_clicked(qreal sx, qreal sy);
   Q_INVOKABLE void on_right_click(qreal sx, qreal sy);
@@ -176,6 +178,11 @@ public:
   Q_INVOKABLE [[nodiscard]] bool any_selected_in_guard_mode() const;
   Q_INVOKABLE [[nodiscard]] bool any_selected_in_formation_mode() const;
   Q_INVOKABLE [[nodiscard]] bool any_selected_in_run_mode() const;
+  Q_INVOKABLE [[nodiscard]] bool is_placing_formation() const;
+  Q_INVOKABLE void on_formation_mouse_move(qreal sx, qreal sy);
+  Q_INVOKABLE void on_formation_scroll(float delta);
+  Q_INVOKABLE void on_formation_confirm();
+  Q_INVOKABLE void on_formation_cancel();
   Q_INVOKABLE void on_patrol_click(qreal sx, qreal sy);
 
   Q_INVOKABLE void camera_move(float dx, float dz);
@@ -425,4 +432,5 @@ signals:
   void is_loading_changed();
   void loading_progress_changed(float progress);
   void loading_stage_changed(QString stage_text);
+  void placing_formation_changed();
 };

+ 79 - 4
app/core/input_command_handler.cpp

@@ -72,12 +72,28 @@ void handle_move_command(Engine::Core::World *world,
   QVector3D hit;
   if (picking_service->screen_to_ground(QPointF(sx, sy), *camera,
                                         viewport.width, viewport.height, hit)) {
-    auto targets = Game::Systems::FormationPlanner::spread_formation_by_nation(
-        *world, selected, hit,
-        Game::GameConfig::instance().gameplay().formation_spacing_default);
+    auto formation_result =
+        Game::Systems::FormationPlanner::get_formation_with_facing(
+            *world, selected, hit,
+            Game::GameConfig::instance().gameplay().formation_spacing_default);
+
+    for (size_t i = 0; i < selected.size(); ++i) {
+      auto *entity = world->get_entity(selected[i]);
+      if (entity == nullptr) {
+        continue;
+      }
+      auto *transform =
+          entity->get_component<Engine::Core::TransformComponent>();
+      if (transform != nullptr && i < formation_result.facing_angles.size()) {
+        transform->desired_yaw = formation_result.facing_angles[i];
+        transform->has_desired_yaw = true;
+      }
+    }
+
     Game::Systems::CommandService::MoveOptions opts;
     opts.group_move = selected.size() > 1;
-    Game::Systems::CommandService::moveUnits(*world, selected, targets, opts);
+    Game::Systems::CommandService::moveUnits(*world, selected,
+                                             formation_result.positions, opts);
   }
 }
 } // namespace
@@ -309,6 +325,65 @@ auto InputCommandHandler::any_selected_in_run_mode() const -> bool {
   return m_command_controller->any_selected_in_run_mode();
 }
 
+auto InputCommandHandler::is_placing_formation() const -> bool {
+  if (!m_command_controller) {
+    return false;
+  }
+  return m_command_controller->is_placing_formation();
+}
+
+void InputCommandHandler::on_formation_mouse_move(
+    qreal sx, qreal sy, const ViewportState &viewport) {
+  if (!m_command_controller || !m_camera || !m_picking_service) {
+    return;
+  }
+
+  if (!m_command_controller->is_placing_formation()) {
+    return;
+  }
+
+  QVector3D hit;
+  if (m_picking_service->screen_to_ground(
+          QPointF(sx, sy), *m_camera, viewport.width, viewport.height, hit)) {
+    m_command_controller->update_formation_placement(hit);
+  }
+}
+
+void InputCommandHandler::on_formation_scroll(float delta) {
+  if (!m_command_controller) {
+    return;
+  }
+
+  if (!m_command_controller->is_placing_formation()) {
+    return;
+  }
+
+  float current_angle = m_command_controller->get_formation_placement_angle();
+  float new_angle = current_angle + delta * 5.0F;
+
+  while (new_angle < 0.0F) {
+    new_angle += 360.0F;
+  }
+  while (new_angle >= 360.0F) {
+    new_angle -= 360.0F;
+  }
+  m_command_controller->update_formation_rotation(new_angle);
+}
+
+void InputCommandHandler::on_formation_confirm() {
+  if (!m_command_controller) {
+    return;
+  }
+  m_command_controller->confirm_formation_placement();
+}
+
+void InputCommandHandler::on_formation_cancel() {
+  if (!m_command_controller) {
+    return;
+  }
+  m_command_controller->cancel_formation_placement();
+}
+
 void InputCommandHandler::on_patrol_click(qreal sx, qreal sy,
                                           const ViewportState &viewport) {
   if (m_is_spectator_mode) {

+ 6 - 0
app/core/input_command_handler.h

@@ -58,6 +58,12 @@ public:
   [[nodiscard]] bool any_selected_in_guard_mode() const;
   [[nodiscard]] bool any_selected_in_formation_mode() const;
   [[nodiscard]] bool any_selected_in_run_mode() const;
+  [[nodiscard]] bool is_placing_formation() const;
+  void on_formation_mouse_move(qreal sx, qreal sy,
+                               const ViewportState &viewport);
+  void on_formation_scroll(float delta);
+  void on_formation_confirm();
+  void on_formation_cancel();
   void on_patrol_click(qreal sx, qreal sy, const ViewportState &viewport);
   void on_click_select(qreal sx, qreal sy, bool additive, int local_owner_id,
                        const ViewportState &viewport);

+ 141 - 14
game/systems/formation_planner.h

@@ -4,8 +4,10 @@
 #include "../core/entity.h"
 #include "../core/world.h"
 #include "../units/spawn_type.h"
+#include "command_service.h"
 #include "formation_system.h"
 #include "nation_registry.h"
+#include "pathfinding.h"
 #include <QVector3D>
 #include <cmath>
 #include <unordered_map>
@@ -13,6 +15,12 @@
 
 namespace Game::Systems {
 
+struct FormationResult {
+  std::vector<QVector3D> positions;
+  std::vector<float> facing_angles;
+  float formation_facing = 0.0F;
+};
+
 class FormationPlanner {
 public:
   static auto spreadFormation(int n, const QVector3D &center,
@@ -33,13 +41,116 @@ public:
     return out;
   }
 
+private:
+  static auto findNearestWalkable(const QVector3D &position,
+                                  Pathfinding *pathfinder,
+                                  int max_search_radius = 5) -> QVector3D {
+    if (pathfinder == nullptr) {
+      return position;
+    }
+
+    float const offset_x = pathfinder->getGridOffsetX();
+    float const offset_z = pathfinder->getGridOffsetZ();
+
+    int const center_grid_x =
+        static_cast<int>(std::round(position.x() - offset_x));
+    int const center_grid_z =
+        static_cast<int>(std::round(position.z() - offset_z));
+
+    if (pathfinder->isWalkable(center_grid_x, center_grid_z)) {
+      return position;
+    }
+
+    for (int radius = 1; radius <= max_search_radius; ++radius) {
+      for (int dx = -radius; dx <= radius; ++dx) {
+        for (int dz = -radius; dz <= radius; ++dz) {
+          if (std::abs(dx) != radius && std::abs(dz) != radius) {
+            continue;
+          }
+
+          int const test_x = center_grid_x + dx;
+          int const test_z = center_grid_z + dz;
+
+          if (pathfinder->isWalkable(test_x, test_z)) {
+            return QVector3D(static_cast<float>(test_x) + offset_x,
+                             position.y(),
+                             static_cast<float>(test_z) + offset_z);
+          }
+        }
+      }
+    }
+
+    return position;
+  }
+
+  static auto isAreaMostlyWalkable(const QVector3D &center,
+                                   Pathfinding *pathfinder,
+                                   float radius) -> bool {
+    if (pathfinder == nullptr) {
+      return true;
+    }
+
+    float const offset_x = pathfinder->getGridOffsetX();
+    float const offset_z = pathfinder->getGridOffsetZ();
+
+    int const center_grid_x =
+        static_cast<int>(std::round(center.x() - offset_x));
+    int const center_grid_z =
+        static_cast<int>(std::round(center.z() - offset_z));
+
+    int const check_radius = static_cast<int>(std::ceil(radius));
+    int walkable_count = 0;
+    int total_count = 0;
+
+    for (int dx = -check_radius; dx <= check_radius; ++dx) {
+      for (int dz = -check_radius; dz <= check_radius; ++dz) {
+        int const test_x = center_grid_x + dx;
+        int const test_z = center_grid_z + dz;
+
+        ++total_count;
+        if (pathfinder->isWalkable(test_x, test_z)) {
+          ++walkable_count;
+        }
+      }
+    }
+
+    return walkable_count >= (total_count * 2 / 3);
+  }
+
+public:
   static auto
   spread_formation_by_nation(Engine::Core::World &world,
                              const std::vector<Engine::Core::EntityID> &units,
                              const QVector3D &center,
                              float spacing = 1.0F) -> std::vector<QVector3D> {
+    auto result = get_formation_with_facing(world, units, center, spacing);
+    return result.positions;
+  }
+
+  static auto
+  get_formation_with_facing(Engine::Core::World &world,
+                            const std::vector<Engine::Core::EntityID> &units,
+                            const QVector3D &center,
+                            float spacing = 1.0F) -> FormationResult {
+    FormationResult result;
     if (units.empty()) {
-      return {};
+      return result;
+    }
+
+    result.positions.resize(units.size(), center);
+    result.facing_angles.resize(units.size(), 0.0F);
+
+    auto *pathfinder = CommandService::getPathfinder();
+
+    QVector3D adjusted_center = center;
+    if (pathfinder != nullptr) {
+      float const estimated_formation_radius =
+          std::sqrt(static_cast<float>(units.size())) * spacing * 2.0F;
+
+      if (!isAreaMostlyWalkable(center, pathfinder,
+                                estimated_formation_radius)) {
+        adjusted_center = findNearestWalkable(center, pathfinder, 15);
+      }
     }
 
     bool all_in_formation_mode = true;
@@ -74,12 +185,20 @@ public:
     }
 
     if (!all_in_formation_mode || !formation_type_determined) {
-      return spreadFormation(int(units.size()), center, spacing);
+      result.positions =
+          spreadFormation(int(units.size()), adjusted_center, spacing);
+      result.facing_angles.assign(units.size(), 0.0F);
+      return result;
     }
 
     std::vector<UnitFormationInfo> unit_infos;
     unit_infos.reserve(units.size());
 
+    std::unordered_map<Engine::Core::EntityID, size_t> unit_to_original_idx;
+    for (size_t i = 0; i < units.size(); ++i) {
+      unit_to_original_idx[units[i]] = i;
+    }
+
     for (auto unit_id : units) {
       auto *entity = world.get_entity(unit_id);
       if (entity == nullptr) {
@@ -106,29 +225,37 @@ public:
     }
 
     if (unit_infos.empty()) {
-      return spreadFormation(int(units.size()), center, spacing);
+      result.positions =
+          spreadFormation(int(units.size()), adjusted_center, spacing);
+      result.facing_angles.assign(units.size(), 0.0F);
+      return result;
     }
 
+    std::sort(unit_infos.begin(), unit_infos.end(),
+              [](const UnitFormationInfo &a, const UnitFormationInfo &b) {
+                if (a.troop_type != b.troop_type) {
+                  return static_cast<int>(a.troop_type) <
+                         static_cast<int>(b.troop_type);
+                }
+                return a.entity_id < b.entity_id;
+              });
+
     auto formation_positions =
         FormationSystem::instance().get_formation_positions_with_facing(
-            formation_type, unit_infos, center, spacing);
-
-    std::vector<QVector3D> positions;
-    positions.resize(units.size(), center);
-
-    std::unordered_map<Engine::Core::EntityID, size_t> unit_to_original_idx;
-    for (size_t i = 0; i < units.size(); ++i) {
-      unit_to_original_idx[units[i]] = i;
-    }
+            formation_type, unit_infos, adjusted_center, spacing);
 
     for (const auto &fpos : formation_positions) {
       auto it = unit_to_original_idx.find(fpos.entity_id);
       if (it != unit_to_original_idx.end()) {
-        positions[it->second] = fpos.position;
+        size_t const original_idx = it->second;
+        result.positions[original_idx] = fpos.position;
+        result.facing_angles[original_idx] = fpos.facing_angle;
       }
     }
 
-    return positions;
+    result.formation_facing = 0.0F;
+
+    return result;
   }
 };
 

+ 223 - 113
game/systems/formation_system.cpp

@@ -1,11 +1,11 @@
 #include "formation_system.h"
+#include "../units/troop_config.h"
 #include <QDebug>
 #include <algorithm>
 #include <cmath>
 #include <memory>
 #include <qglobal.h>
 #include <qvectornd.h>
-#include <random>
 #include <utility>
 #include <vector>
 
@@ -17,6 +17,13 @@ constexpr float ROMAN_UNIT_SPACING = 2.5F;
 constexpr float CARTHAGE_LINE_SPACING = 3.0F;
 constexpr float CARTHAGE_UNIT_SPACING = 2.8F;
 
+auto get_unit_spacing(Game::Units::TroopType type,
+                      float base_spacing) -> float {
+  float const selection_size =
+      Game::Units::TroopConfig::instance().getSelectionRingSize(type);
+  return (selection_size * 2.0F + 0.5F) * base_spacing;
+}
+
 auto is_infantry(Game::Units::TroopType type) -> bool {
   return type == Game::Units::TroopType::Swordsman ||
          type == Game::Units::TroopType::Spearman;
@@ -38,7 +45,36 @@ auto is_siege(Game::Units::TroopType type) -> bool {
 }
 
 auto is_support(Game::Units::TroopType type) -> bool {
-  return type == Game::Units::TroopType::Healer;
+  return type == Game::Units::TroopType::Healer ||
+         type == Game::Units::TroopType::Builder;
+}
+
+auto calculate_balanced_rows(int total_units, int max_per_row,
+                             int min_per_row) -> std::vector<int> {
+  std::vector<int> row_sizes;
+
+  if (total_units <= 0) {
+    return row_sizes;
+  }
+
+  if (total_units <= max_per_row) {
+    row_sizes.push_back(total_units);
+    return row_sizes;
+  }
+
+  int num_rows = (total_units + max_per_row - 1) / max_per_row;
+
+  int base_per_row = total_units / num_rows;
+  int extra_units = total_units % num_rows;
+
+  for (int r = 0; r < num_rows; ++r) {
+
+    int units_in_row = base_per_row + (r < extra_units ? 1 : 0);
+    units_in_row = std::max(min_per_row, std::min(max_per_row, units_in_row));
+    row_sizes.push_back(units_in_row);
+  }
+
+  return row_sizes;
 }
 } // namespace
 
@@ -106,71 +142,107 @@ auto RomanFormation::calculateFormationPositions(
   float const forward_facing = 0.0F;
   float row_offset = 0.0F;
 
+  float max_row_width = 0.0F;
+
   if (!infantry.empty()) {
-    int const units_per_row =
-        std::max(3, std::min(8, static_cast<int>(infantry.size())));
-    float const spacing = ROMAN_UNIT_SPACING * base_spacing;
+    int const max_per_row = std::min(8, static_cast<int>(infantry.size()));
+    int const min_per_row = std::max(3, max_per_row / 2);
+    auto row_sizes = calculate_balanced_rows(static_cast<int>(infantry.size()),
+                                             max_per_row, min_per_row);
 
-    for (size_t i = 0; i < infantry.size(); ++i) {
-      int const row = static_cast<int>(i) / units_per_row;
-      int const col = static_cast<int>(i) % units_per_row;
+    float const unit_spacing =
+        get_unit_spacing(infantry[0].troop_type, base_spacing);
 
-      float const x_offset = (col - (units_per_row - 1) * 0.5F) * spacing;
-      float const z_offset =
-          row_offset + row * ROMAN_LINE_SPACING * base_spacing;
+    float const row_spacing = unit_spacing;
 
-      FormationPosition pos;
-      pos.position =
-          QVector3D(center.x() + x_offset, center.y(), center.z() + z_offset);
-      pos.facing_angle = forward_facing;
-      pos.entity_id = infantry[i].entity_id;
-      positions.push_back(pos);
+    int max_units_in_row = 0;
+    for (int size : row_sizes) {
+      max_units_in_row = std::max(max_units_in_row, size);
+    }
+    float const type_max_width = (max_units_in_row - 1) * unit_spacing;
+    max_row_width = std::max(max_row_width, type_max_width);
+
+    size_t unit_idx = 0;
+    for (size_t row = 0; row < row_sizes.size() && unit_idx < infantry.size();
+         ++row) {
+      int const units_in_row = row_sizes[row];
+
+      for (int col = 0; col < units_in_row && unit_idx < infantry.size();
+           ++col) {
+
+        float const x_offset = (col - (units_in_row - 1) * 0.5F) * unit_spacing;
+
+        float const z_offset =
+            row_offset - static_cast<float>(row) * row_spacing;
+
+        FormationPosition pos;
+        pos.position =
+            QVector3D(center.x() + x_offset, center.y(), center.z() + z_offset);
+        pos.facing_angle = forward_facing;
+        pos.entity_id = infantry[unit_idx].entity_id;
+        positions.push_back(pos);
+        ++unit_idx;
+      }
     }
 
-    int const inf_rows =
-        (static_cast<int>(infantry.size()) + units_per_row - 1) / units_per_row;
-    row_offset += inf_rows * ROMAN_LINE_SPACING * base_spacing;
+    row_offset -= static_cast<float>(row_sizes.size()) * row_spacing;
   }
 
   if (!archers.empty()) {
-    int const units_per_row =
-        std::max(4, std::min(10, static_cast<int>(archers.size())));
-    float const spacing = ROMAN_UNIT_SPACING * base_spacing * 0.9F;
+    int const max_per_row = std::min(10, static_cast<int>(archers.size()));
+    int const min_per_row = std::max(4, max_per_row / 2);
+    auto row_sizes = calculate_balanced_rows(static_cast<int>(archers.size()),
+                                             max_per_row, min_per_row);
 
-    for (size_t i = 0; i < archers.size(); ++i) {
-      int const row = static_cast<int>(i) / units_per_row;
-      int const col = static_cast<int>(i) % units_per_row;
+    float const unit_spacing =
+        get_unit_spacing(archers[0].troop_type, base_spacing);
 
-      float const x_offset = (col - (units_per_row - 1) * 0.5F) * spacing;
-      float const z_offset =
-          row_offset + row * ROMAN_LINE_SPACING * base_spacing;
+    float const row_spacing = unit_spacing;
 
-      FormationPosition pos;
-      pos.position =
-          QVector3D(center.x() + x_offset, center.y(), center.z() + z_offset);
-      pos.facing_angle = forward_facing;
-      pos.entity_id = archers[i].entity_id;
-      positions.push_back(pos);
+    int max_units_in_row = 0;
+    for (int size : row_sizes) {
+      max_units_in_row = std::max(max_units_in_row, size);
     }
+    float const type_max_width = (max_units_in_row - 1) * unit_spacing;
+    max_row_width = std::max(max_row_width, type_max_width);
+
+    size_t unit_idx = 0;
+    for (size_t row = 0; row < row_sizes.size() && unit_idx < archers.size();
+         ++row) {
+      int const units_in_row = row_sizes[row];
+
+      for (int col = 0; col < units_in_row && unit_idx < archers.size();
+           ++col) {
 
-    int const archer_rows =
-        (static_cast<int>(archers.size()) + units_per_row - 1) / units_per_row;
-    row_offset += archer_rows * ROMAN_LINE_SPACING * base_spacing;
+        float const x_offset = (col - (units_in_row - 1) * 0.5F) * unit_spacing;
+
+        float const z_offset =
+            row_offset - static_cast<float>(row) * row_spacing;
+
+        FormationPosition pos;
+        pos.position =
+            QVector3D(center.x() + x_offset, center.y(), center.z() + z_offset);
+        pos.facing_angle = forward_facing;
+        pos.entity_id = archers[unit_idx].entity_id;
+        positions.push_back(pos);
+        ++unit_idx;
+      }
+    }
+
+    row_offset -= static_cast<float>(row_sizes.size()) * row_spacing;
   }
 
   if (!cavalry.empty()) {
-    float const flank_spacing = ROMAN_UNIT_SPACING * base_spacing * 1.2F;
-    float const cavalry_z_offset =
-        center.z() - ROMAN_LINE_SPACING * base_spacing * 0.5F;
+    float const cavalry_z_offset = center.z();
 
     for (size_t i = 0; i < cavalry.size(); ++i) {
+      float const spacing =
+          get_unit_spacing(cavalry[i].troop_type, base_spacing) * 1.2F;
       float x_offset;
       if (i % 2 == 0) {
-
-        x_offset = (i / 2 + 1) * flank_spacing + 5.0F * base_spacing;
+        x_offset = (i / 2 + 1) * spacing + 5.0F * base_spacing;
       } else {
-
-        x_offset = -((i / 2 + 1) * flank_spacing + 5.0F * base_spacing);
+        x_offset = -((i / 2 + 1) * spacing + 5.0F * base_spacing);
       }
 
       FormationPosition pos;
@@ -183,13 +255,14 @@ auto RomanFormation::calculateFormationPositions(
   }
 
   if (!siege.empty()) {
-    float const spacing = ROMAN_UNIT_SPACING * base_spacing * 1.5F;
+    float const spacing =
+        get_unit_spacing(siege[0].troop_type, base_spacing) * 1.5F;
 
     for (size_t i = 0; i < siege.size(); ++i) {
       float const x_offset =
           (static_cast<int>(i) - (static_cast<int>(siege.size()) - 1) * 0.5F) *
           spacing;
-      float const z_offset = row_offset + ROMAN_LINE_SPACING * base_spacing;
+      float const z_offset = row_offset - ROMAN_LINE_SPACING * base_spacing;
 
       FormationPosition pos;
       pos.position =
@@ -199,17 +272,17 @@ auto RomanFormation::calculateFormationPositions(
       positions.push_back(pos);
     }
 
-    row_offset += ROMAN_LINE_SPACING * base_spacing * 1.5F;
+    row_offset -= ROMAN_LINE_SPACING * base_spacing * 1.5F;
   }
 
   if (!support.empty()) {
-    float const spacing = ROMAN_UNIT_SPACING * base_spacing;
+    float const spacing = get_unit_spacing(support[0].troop_type, base_spacing);
 
     for (size_t i = 0; i < support.size(); ++i) {
       float const x_offset = (static_cast<int>(i) -
                               (static_cast<int>(support.size()) - 1) * 0.5F) *
                              spacing;
-      float const z_offset = row_offset;
+      float const z_offset = row_offset - ROMAN_LINE_SPACING * base_spacing;
 
       FormationPosition pos;
       pos.position =
@@ -260,9 +333,6 @@ auto BarbarianFormation::calculatePositions(
     spacing *= 1.5F;
   }
 
-  std::mt19937 rng(42);
-  std::uniform_real_distribution<float> dist(-0.3F, 0.3F);
-
   int const side = std::ceil(std::sqrt(static_cast<float>(unit_count)));
 
   for (int i = 0; i < unit_count; ++i) {
@@ -272,11 +342,8 @@ auto BarbarianFormation::calculatePositions(
     float const base_x = (gx - (side - 1) * 0.5F) * spacing;
     float const base_z = (gy - (side - 1) * 0.5F) * spacing;
 
-    float const jitter_x = dist(rng) * spacing;
-    float const jitter_z = dist(rng) * spacing;
-
-    positions.emplace_back(center.x() + base_x + jitter_x, center.y(),
-                           center.z() + base_z + jitter_z);
+    positions.emplace_back(center.x() + base_x, center.y(),
+                           center.z() + base_z);
   }
 
   return positions;
@@ -311,69 +378,113 @@ auto CarthageFormation::calculateFormationPositions(
   float const forward_facing = 0.0F;
   float row_offset = 0.0F;
 
-  if (!siege.empty()) {
-    float const spacing = CARTHAGE_UNIT_SPACING * base_spacing * 2.0F;
-
-    for (size_t i = 0; i < siege.size(); ++i) {
-      float const x_offset =
-          (static_cast<int>(i) - (static_cast<int>(siege.size()) - 1) * 0.5F) *
-          spacing;
-
-      FormationPosition pos;
-      pos.position =
-          QVector3D(center.x() + x_offset, center.y(), center.z() + row_offset);
-      pos.facing_angle = forward_facing;
-      pos.entity_id = siege[i].entity_id;
-      positions.push_back(pos);
+  if (!infantry.empty()) {
+    int const max_per_row = std::min(7, static_cast<int>(infantry.size()));
+    int const min_per_row = std::max(4, max_per_row / 2);
+    auto row_sizes = calculate_balanced_rows(static_cast<int>(infantry.size()),
+                                             max_per_row, min_per_row);
+
+    float const spacing =
+        get_unit_spacing(infantry[0].troop_type, base_spacing);
+
+    size_t unit_idx = 0;
+    for (size_t row = 0; row < row_sizes.size() && unit_idx < infantry.size();
+         ++row) {
+      int const units_in_row = row_sizes[row];
+      float const row_echelon = static_cast<float>(row) * 0.8F * spacing;
+
+      for (int col = 0; col < units_in_row && unit_idx < infantry.size();
+           ++col) {
+        float const x_offset =
+            (col - (units_in_row - 1) * 0.5F) * spacing + row_echelon;
+        float const z_offset = row_offset - static_cast<float>(row) *
+                                                CARTHAGE_LINE_SPACING *
+                                                base_spacing;
+
+        FormationPosition pos;
+        pos.position =
+            QVector3D(center.x() + x_offset, center.y(), center.z() + z_offset);
+        pos.facing_angle = forward_facing;
+        pos.entity_id = infantry[unit_idx].entity_id;
+        positions.push_back(pos);
+        ++unit_idx;
+      }
     }
 
-    row_offset += CARTHAGE_LINE_SPACING * base_spacing * 1.5F;
+    row_offset -= static_cast<float>(row_sizes.size()) * CARTHAGE_LINE_SPACING *
+                  base_spacing;
   }
 
-  std::vector<UnitFormationInfo> center_units;
-  center_units.insert(center_units.end(), infantry.begin(), infantry.end());
-  center_units.insert(center_units.end(), archers.begin(), archers.end());
-
-  if (!center_units.empty()) {
-    int const units_per_row =
-        std::max(4, std::min(7, static_cast<int>(center_units.size())));
-    float const spacing = CARTHAGE_UNIT_SPACING * base_spacing;
+  if (!archers.empty()) {
+    int const max_per_row = std::min(9, static_cast<int>(archers.size()));
+    int const min_per_row = std::max(5, max_per_row / 2);
+    auto row_sizes = calculate_balanced_rows(static_cast<int>(archers.size()),
+                                             max_per_row, min_per_row);
+
+    float const spacing = get_unit_spacing(archers[0].troop_type, base_spacing);
+
+    size_t unit_idx = 0;
+    for (size_t row = 0; row < row_sizes.size() && unit_idx < archers.size();
+         ++row) {
+      int const units_in_row = row_sizes[row];
+      float const row_echelon = -static_cast<float>(row) * 0.8F * spacing;
+
+      for (int col = 0; col < units_in_row && unit_idx < archers.size();
+           ++col) {
+        float const x_offset =
+            (col - (units_in_row - 1) * 0.5F) * spacing + row_echelon;
+        float const z_offset = row_offset - static_cast<float>(row) *
+                                                CARTHAGE_LINE_SPACING *
+                                                base_spacing;
+
+        FormationPosition pos;
+        pos.position =
+            QVector3D(center.x() + x_offset, center.y(), center.z() + z_offset);
+        pos.facing_angle = forward_facing;
+        pos.entity_id = archers[unit_idx].entity_id;
+        positions.push_back(pos);
+        ++unit_idx;
+      }
+    }
 
-    for (size_t i = 0; i < center_units.size(); ++i) {
-      int const row = static_cast<int>(i) / units_per_row;
-      int const col = static_cast<int>(i) % units_per_row;
+    row_offset -= static_cast<float>(row_sizes.size()) * CARTHAGE_LINE_SPACING *
+                  base_spacing;
+  }
 
-      float const x_offset = (col - (units_per_row - 1) * 0.5F) * spacing;
-      float const z_offset =
-          row_offset + row * CARTHAGE_LINE_SPACING * base_spacing;
+  if (!siege.empty()) {
+    for (size_t i = 0; i < siege.size(); ++i) {
+      float const spacing =
+          get_unit_spacing(siege[i].troop_type, base_spacing) * 2.0F;
+      float const x_offset =
+          (static_cast<int>(i) - (static_cast<int>(siege.size()) - 1) * 0.5F) *
+          spacing;
 
       FormationPosition pos;
-      pos.position =
-          QVector3D(center.x() + x_offset, center.y(), center.z() + z_offset);
+      pos.position = QVector3D(center.x() + x_offset, center.y(),
+                               center.z() + row_offset -
+                                   CARTHAGE_LINE_SPACING * base_spacing);
       pos.facing_angle = forward_facing;
-      pos.entity_id = center_units[i].entity_id;
+      pos.entity_id = siege[i].entity_id;
       positions.push_back(pos);
     }
 
-    int const center_rows =
-        (static_cast<int>(center_units.size()) + units_per_row - 1) /
-        units_per_row;
-    row_offset += center_rows * CARTHAGE_LINE_SPACING * base_spacing;
+    row_offset -= CARTHAGE_LINE_SPACING * base_spacing * 1.5F;
   }
 
   if (!cavalry.empty()) {
-    float const flank_spacing = CARTHAGE_UNIT_SPACING * base_spacing * 1.3F;
-
-    float const cavalry_z_offset =
-        center.z() - CARTHAGE_LINE_SPACING * base_spacing * 1.0F;
+    float const cavalry_z_offset = center.z();
 
     int const right_flank_count = (static_cast<int>(cavalry.size()) + 1) / 2;
     int const left_flank_count =
         static_cast<int>(cavalry.size()) - right_flank_count;
 
     for (int i = 0; i < right_flank_count; ++i) {
-      float const x_offset = (i + 1) * flank_spacing + 6.0F * base_spacing;
-      float const z_forward = i * -0.5F * base_spacing;
+      float const spacing =
+          get_unit_spacing(cavalry[static_cast<size_t>(i)].troop_type,
+                           base_spacing) *
+          1.3F;
+      float const x_offset = (i + 1) * spacing + 6.0F * base_spacing;
+      float const z_forward = i * 0.7F * base_spacing;
 
       FormationPosition pos;
       pos.position = QVector3D(center.x() + x_offset, center.y(),
@@ -384,8 +495,13 @@ auto CarthageFormation::calculateFormationPositions(
     }
 
     for (int i = 0; i < left_flank_count; ++i) {
-      float const x_offset = -((i + 1) * flank_spacing + 6.0F * base_spacing);
-      float const z_forward = i * -0.3F * base_spacing;
+      float const spacing =
+          get_unit_spacing(
+              cavalry[static_cast<size_t>(right_flank_count + i)].troop_type,
+              base_spacing) *
+          1.3F;
+      float const x_offset = -((i + 1) * spacing + 6.0F * base_spacing);
+      float const z_forward = i * 0.7F * base_spacing;
 
       FormationPosition pos;
       pos.position = QVector3D(center.x() + x_offset, center.y(),
@@ -398,13 +514,13 @@ auto CarthageFormation::calculateFormationPositions(
   }
 
   if (!support.empty()) {
-    float const spacing = CARTHAGE_UNIT_SPACING * base_spacing;
-
     for (size_t i = 0; i < support.size(); ++i) {
+      float const spacing =
+          get_unit_spacing(support[i].troop_type, base_spacing);
       float const x_offset = (static_cast<int>(i) -
                               (static_cast<int>(support.size()) - 1) * 0.5F) *
                              spacing;
-      float const z_offset = row_offset + CARTHAGE_LINE_SPACING * base_spacing;
+      float const z_offset = row_offset - CARTHAGE_LINE_SPACING * base_spacing;
 
       FormationPosition pos;
       pos.position =
@@ -436,9 +552,6 @@ auto CarthageFormation::calculatePositions(
     spacing *= 1.5F;
   }
 
-  std::mt19937 rng(84);
-  std::uniform_real_distribution<float> dist(-0.25F, 0.25F);
-
   int const rows = std::max(1, static_cast<int>(std::sqrt(unit_count * 0.8F)));
   int const cols = (unit_count + rows - 1) / rows;
 
@@ -449,11 +562,8 @@ auto CarthageFormation::calculatePositions(
     float const base_x = (col - (cols - 1) * 0.5F) * spacing;
     float const base_z = (row - (rows - 1) * 0.5F) * spacing * 0.85F;
 
-    float const jitter_x = dist(rng) * spacing;
-    float const jitter_z = dist(rng) * spacing;
-
-    positions.emplace_back(center.x() + base_x + jitter_x, center.y(),
-                           center.z() + base_z + jitter_z);
+    positions.emplace_back(center.x() + base_x, center.y(),
+                           center.z() + base_z);
   }
 
   return positions;

+ 1 - 0
render/CMakeLists.txt

@@ -104,6 +104,7 @@ add_library(render_gl STATIC
     geom/flag.cpp
     geom/banner_cloth.cpp
     geom/patrol_flags.cpp
+    geom/formation_arrow.cpp
     geom/transforms.cpp
     humanoid/humanoid_math.cpp
     gl/humanoid/animation/animation_inputs.cpp

+ 84 - 23
render/entity/nations/carthage/defense_tower_renderer.cpp

@@ -19,12 +19,21 @@ using Render::Geom::clampVec01;
 using Render::Geom::cylinder_between;
 
 struct TowerPalette {
+  QVector3D limestone{0.96F, 0.94F, 0.88F};
+  QVector3D limestone_shade{0.88F, 0.85F, 0.78F};
+  QVector3D limestone_dark{0.80F, 0.76F, 0.70F};
   QVector3D sandstone_light{0.82F, 0.75F, 0.62F};
   QVector3D sandstone_dark{0.70F, 0.62F, 0.50F};
   QVector3D sandstone_base{0.75F, 0.68F, 0.56F};
+  QVector3D marble{0.98F, 0.97F, 0.95F};
   QVector3D terracotta{0.80F, 0.55F, 0.38F};
-  QVector3D wood{0.48F, 0.32F, 0.20F};
+  QVector3D terracotta_dark{0.68F, 0.48F, 0.32F};
+  QVector3D cedar{0.52F, 0.38F, 0.26F};
+  QVector3D cedar_dark{0.38F, 0.26F, 0.16F};
+  QVector3D blue_accent{0.28F, 0.48F, 0.68F};
+  QVector3D blue_light{0.40F, 0.60F, 0.80F};
   QVector3D bronze{0.60F, 0.45F, 0.25F};
+  QVector3D gold{0.85F, 0.72F, 0.35F};
   QVector3D team{0.8F, 0.9F, 1.0F};
 };
 
@@ -52,51 +61,103 @@ inline void draw_cyl(ISubmitter &out, const QMatrix4x4 &model,
 
 void draw_tower_base(const DrawContext &p, ISubmitter &out, Mesh *unit,
                      Texture *white, const TowerPalette &c) {
-  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.2F, 0.0F),
-           QVector3D(0.85F, 0.2F, 0.85F), c.sandstone_base);
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.12F, 0.0F),
+           QVector3D(1.1F, 0.12F, 1.1F), c.limestone_dark);
+
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.26F, 0.0F),
+           QVector3D(1.0F, 0.02F, 1.0F), c.limestone);
+
+  for (float x = -0.85F; x <= 0.85F; x += 0.425F) {
+    for (float z = -0.85F; z <= 0.85F; z += 0.425F) {
+      if (fabsf(x) > 0.3F || fabsf(z) > 0.3F) {
+        draw_box(out, unit, white, p.model, QVector3D(x, 0.29F, z),
+                 QVector3D(0.18F, 0.01F, 0.18F), c.terracotta);
+      }
+    }
+  }
 
-  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.5F, 0.0F),
-           QVector3D(0.75F, 0.3F, 0.75F), c.sandstone_light);
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.42F, 0.0F),
+           QVector3D(0.9F, 0.12F, 0.9F), c.sandstone_light);
 }
 
 void draw_tower_body(const DrawContext &p, ISubmitter &out, Mesh *unit,
                      Texture *white, const TowerPalette &c) {
-  draw_cyl(out, p.model, QVector3D(0.0F, 0.4F, 0.0F),
-           QVector3D(0.0F, 2.1F, 0.0F), 0.5F, c.sandstone_light, white);
+  draw_cyl(out, p.model, QVector3D(0.0F, 0.5F, 0.0F),
+           QVector3D(0.0F, 2.2F, 0.0F), 0.55F, c.limestone, white);
 
   for (int i = 0; i < 4; ++i) {
     float const angle = static_cast<float>(i) * 1.57F + 0.785F;
-    float const ox = sinf(angle) * 0.42F;
-    float const oz = cosf(angle) * 0.42F;
-    draw_box(out, unit, white, p.model, QVector3D(ox, 1.3F, oz),
-             QVector3D(0.08F, 0.7F, 0.08F), c.sandstone_dark);
+    float const ox = sinf(angle) * 0.48F;
+    float const oz = cosf(angle) * 0.48F;
+
+    draw_cyl(out, p.model, QVector3D(ox, 0.5F, oz), QVector3D(ox, 1.9F, oz),
+             0.08F, c.marble, white);
+
+    draw_box(out, unit, white, p.model, QVector3D(ox, 0.58F, oz),
+             QVector3D(0.12F, 0.08F, 0.12F), c.marble);
+
+    draw_box(out, unit, white, p.model, QVector3D(ox, 1.95F, oz),
+             QVector3D(0.13F, 0.08F, 0.13F), c.marble);
+
+    draw_box(out, unit, white, p.model, QVector3D(ox, 2.05F, oz),
+             QVector3D(0.10F, 0.04F, 0.10F), c.gold);
+  }
+
+  for (int i = 0; i < 8; ++i) {
+    float const angle = static_cast<float>(i) * 0.785F;
+    float const ox = sinf(angle) * 0.45F;
+    float const oz = cosf(angle) * 0.45F;
+    draw_box(out, unit, white, p.model, QVector3D(ox, 1.2F, oz),
+             QVector3D(0.06F, 0.25F, 0.06F), c.sandstone_dark);
   }
 }
 
 void draw_tower_platform(const DrawContext &p, ISubmitter &out, Mesh *unit,
                          Texture *white, const TowerPalette &c) {
-  draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.15F, 0.0F),
-           QVector3D(0.7F, 0.05F, 0.7F), c.wood);
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.28F, 0.0F),
+           QVector3D(0.8F, 0.05F, 0.8F), c.cedar);
 
   for (int i = 0; i < 8; ++i) {
     float const angle = static_cast<float>(i) * 0.785F;
-    float const ox = sinf(angle) * 0.6F;
-    float const oz = cosf(angle) * 0.6F;
-    draw_box(out, unit, white, p.model, QVector3D(ox, 2.28F, oz),
-             QVector3D(0.12F, 0.13F, 0.12F), c.terracotta);
+    float const ox = sinf(angle) * 0.7F;
+    float const oz = cosf(angle) * 0.7F;
+    draw_box(out, unit, white, p.model, QVector3D(ox, 2.45F, oz),
+             QVector3D(0.14F, 0.17F, 0.14F), c.terracotta);
+  }
+
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.58F, 0.0F),
+           QVector3D(0.85F, 0.04F, 0.85F), c.limestone);
+
+  for (float x : {-0.75F, 0.75F}) {
+    for (float z : {-0.75F, 0.75F}) {
+      draw_box(out, unit, white, p.model, QVector3D(x, 2.64F, z),
+               QVector3D(0.06F, 0.06F, 0.06F), c.blue_accent);
+    }
   }
 }
 
 void draw_tower_top(const DrawContext &p, ISubmitter &out, Mesh *unit,
                     Texture *white, const TowerPalette &c) {
-  draw_cyl(out, p.model, QVector3D(0.0F, 2.1F, 0.0F),
-           QVector3D(0.0F, 2.9F, 0.0F), 0.05F, c.wood, white);
+  draw_cyl(out, p.model, QVector3D(0.0F, 2.25F, 0.0F),
+           QVector3D(0.0F, 3.1F, 0.0F), 0.07F, c.cedar_dark, white);
+
+  draw_box(out, unit, white, p.model, QVector3D(0.12F, 2.75F, 0.0F),
+           QVector3D(0.22F, 0.15F, 0.025F), c.team);
+
+  for (int i = 0; i < 4; ++i) {
+    float ring_y = 2.45F + static_cast<float>(i) * 0.25F;
+    out.mesh(get_unit_cylinder(),
+             p.model * Render::Geom::cylinder_between(
+                           QVector3D(0.0F, ring_y, 0.0F),
+                           QVector3D(0.0F, ring_y + 0.025F, 0.0F), 0.11F),
+             c.gold, white, 1.0F);
+  }
 
-  draw_box(out, unit, white, p.model, QVector3D(0.1F, 2.6F, 0.0F),
-           QVector3D(0.18F, 0.12F, 0.02F), c.team);
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 3.15F, 0.0F),
+           QVector3D(0.08F, 0.06F, 0.08F), c.bronze);
 
-  draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.95F, 0.0F),
-           QVector3D(0.06F, 0.04F, 0.06F), c.bronze);
+  draw_box(out, unit, white, p.model, QVector3D(0.18F, 3.08F, 0.0F),
+           QVector3D(0.3F, 0.025F, 0.015F), c.gold);
 }
 
 void draw_health_bar(const DrawContext &p, ISubmitter &out, Mesh *unit,

+ 65 - 26
render/entity/nations/roman/defense_tower_renderer.cpp

@@ -19,11 +19,14 @@ using Render::Geom::clampVec01;
 using Render::Geom::cylinder_between;
 
 struct TowerPalette {
-  QVector3D stone_light{0.65F, 0.63F, 0.60F};
-  QVector3D stone_dark{0.52F, 0.50F, 0.48F};
-  QVector3D stone_base{0.58F, 0.55F, 0.53F};
-  QVector3D brick{0.72F, 0.50F, 0.40F};
+  QVector3D stone_light{0.62F, 0.60F, 0.58F};
+  QVector3D stone_dark{0.50F, 0.48F, 0.46F};
+  QVector3D stone_base{0.55F, 0.53F, 0.51F};
+  QVector3D brick{0.75F, 0.52F, 0.42F};
+  QVector3D brick_dark{0.62F, 0.42F, 0.32F};
+  QVector3D tile_red{0.72F, 0.40F, 0.30F};
   QVector3D wood{0.42F, 0.28F, 0.16F};
+  QVector3D wood_dark{0.32F, 0.20F, 0.10F};
   QVector3D iron{0.35F, 0.35F, 0.38F};
   QVector3D team{0.8F, 0.9F, 1.0F};
 };
@@ -52,51 +55,87 @@ inline void draw_cyl(ISubmitter &out, const QMatrix4x4 &model,
 
 void draw_tower_base(const DrawContext &p, ISubmitter &out, Mesh *unit,
                      Texture *white, const TowerPalette &c) {
-  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.2F, 0.0F),
-           QVector3D(0.8F, 0.2F, 0.8F), c.stone_base);
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.15F, 0.0F),
+           QVector3D(1.0F, 0.15F, 1.0F), c.stone_base);
+
+  for (float x = -0.9F; x <= 0.9F; x += 0.45F) {
+    draw_box(out, unit, white, p.model, QVector3D(x, 0.35F, -0.85F),
+             QVector3D(0.12F, 0.08F, 0.08F), c.brick_dark);
+    draw_box(out, unit, white, p.model, QVector3D(x, 0.35F, 0.85F),
+             QVector3D(0.12F, 0.08F, 0.08F), c.brick_dark);
+  }
+  for (float z = -0.8F; z <= 0.8F; z += 0.4F) {
+    draw_box(out, unit, white, p.model, QVector3D(-0.85F, 0.35F, z),
+             QVector3D(0.08F, 0.08F, 0.12F), c.brick_dark);
+    draw_box(out, unit, white, p.model, QVector3D(0.85F, 0.35F, z),
+             QVector3D(0.08F, 0.08F, 0.12F), c.brick_dark);
+  }
 
-  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.45F, 0.0F),
-           QVector3D(0.75F, 0.25F, 0.75F), c.stone_light);
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 0.5F, 0.0F),
+           QVector3D(0.9F, 0.1F, 0.9F), c.stone_light);
 }
 
 void draw_tower_body(const DrawContext &p, ISubmitter &out, Mesh *unit,
                      Texture *white, const TowerPalette &c) {
   draw_box(out, unit, white, p.model, QVector3D(0.0F, 1.2F, 0.0F),
-           QVector3D(0.65F, 0.75F, 0.65F), c.stone_light);
+           QVector3D(0.75F, 0.7F, 0.75F), c.stone_light);
 
   for (int i = 0; i < 4; ++i) {
     float const angle = static_cast<float>(i) * 1.57F;
-    float const ox = sinf(angle) * 0.55F;
-    float const oz = cosf(angle) * 0.55F;
-    draw_cyl(out, p.model, QVector3D(ox, 0.5F, oz), QVector3D(ox, 2.0F, oz),
-             0.12F, c.stone_dark, white);
+    float const ox = sinf(angle) * 0.65F;
+    float const oz = cosf(angle) * 0.65F;
+    draw_cyl(out, p.model, QVector3D(ox, 0.5F, oz), QVector3D(ox, 1.9F, oz),
+             0.14F, c.stone_dark, white);
+  }
+
+  for (int i = 0; i < 4; ++i) {
+    float const angle = static_cast<float>(i) * 1.57F + 0.785F;
+    float const ox = sinf(angle) * 0.62F;
+    float const oz = cosf(angle) * 0.62F;
+    draw_box(out, unit, white, p.model, QVector3D(ox, 0.9F, oz),
+             QVector3D(0.12F, 0.4F, 0.12F), c.brick);
   }
+
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 1.65F, 0.0F),
+           QVector3D(0.82F, 0.08F, 0.82F), c.brick_dark);
 }
 
 void draw_tower_platform(const DrawContext &p, ISubmitter &out, Mesh *unit,
                          Texture *white, const TowerPalette &c) {
-  draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.05F, 0.0F),
-           QVector3D(0.85F, 0.05F, 0.85F), c.wood);
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 1.95F, 0.0F),
+           QVector3D(0.95F, 0.05F, 0.95F), c.wood);
 
   for (int i = 0; i < 8; ++i) {
     float const angle = static_cast<float>(i) * 0.785F;
-    float const ox = sinf(angle) * 0.7F;
-    float const oz = cosf(angle) * 0.7F;
-    draw_box(out, unit, white, p.model, QVector3D(ox, 2.2F, oz),
-             QVector3D(0.1F, 0.15F, 0.1F), c.stone_dark);
+    float const ox = sinf(angle) * 0.82F;
+    float const oz = cosf(angle) * 0.82F;
+    draw_box(out, unit, white, p.model, QVector3D(ox, 2.12F, oz),
+             QVector3D(0.12F, 0.17F, 0.12F), c.brick);
   }
+
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.32F, 0.0F),
+           QVector3D(1.0F, 0.03F, 1.0F), c.tile_red);
 }
 
 void draw_tower_top(const DrawContext &p, ISubmitter &out, Mesh *unit,
                     Texture *white, const TowerPalette &c) {
-  draw_cyl(out, p.model, QVector3D(0.0F, 2.0F, 0.0F),
-           QVector3D(0.0F, 2.8F, 0.0F), 0.06F, c.wood, white);
-
-  draw_box(out, unit, white, p.model, QVector3D(0.12F, 2.55F, 0.0F),
-           QVector3D(0.2F, 0.15F, 0.02F), c.team);
+  draw_cyl(out, p.model, QVector3D(0.0F, 2.05F, 0.0F),
+           QVector3D(0.0F, 2.9F, 0.0F), 0.08F, c.wood_dark, white);
+
+  draw_box(out, unit, white, p.model, QVector3D(0.15F, 2.6F, 0.0F),
+           QVector3D(0.25F, 0.18F, 0.025F), c.team);
+
+  for (int i = 0; i < 3; ++i) {
+    float ring_y = 2.3F + static_cast<float>(i) * 0.25F;
+    out.mesh(get_unit_cylinder(),
+             p.model * Render::Geom::cylinder_between(
+                           QVector3D(0.0F, ring_y, 0.0F),
+                           QVector3D(0.0F, ring_y + 0.03F, 0.0F), 0.12F),
+             c.iron, white, 1.0F);
+  }
 
-  draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.9F, 0.0F),
-           QVector3D(0.08F, 0.05F, 0.08F), c.iron);
+  draw_box(out, unit, white, p.model, QVector3D(0.0F, 2.95F, 0.0F),
+           QVector3D(0.1F, 0.08F, 0.1F), c.iron);
 }
 
 void draw_health_bar(const DrawContext &p, ISubmitter &out, Mesh *unit,

+ 90 - 0
render/geom/formation_arrow.cpp

@@ -0,0 +1,90 @@
+#include "formation_arrow.h"
+#include "../gl/resources.h"
+#include "../scene_renderer.h"
+#include <QMatrix4x4>
+#include <cmath>
+#include <numbers>
+#include <qvectornd.h>
+
+namespace Render::GL {
+
+void renderFormationArrow(Renderer *renderer, ResourceManager *resources,
+                          const FormationPlacementInfo &placement) {
+  if ((renderer == nullptr) || (resources == nullptr) || !placement.active) {
+    return;
+  }
+
+  float const visual_angle_degrees = placement.angle_degrees + 180.0F;
+
+  QVector3D const arrow_main(0.1F, 0.7F, 0.9F);
+  QVector3D const arrow_accent(0.0F, 0.9F, 1.0F);
+  QVector3D const arrow_edge(0.05F, 0.4F, 0.6F);
+
+  float const arrow_length = 1.5F;
+  float const arrow_width = 0.15F;
+  float const arrow_head_size = 0.6F;
+  float const arrow_height = 0.125F;
+
+  float const base_y = placement.position.y() + 0.12F;
+
+  {
+    QMatrix4x4 shaft_model;
+    shaft_model.translate(placement.position.x(), base_y,
+                          placement.position.z());
+    shaft_model.rotate(visual_angle_degrees, 0.0F, 1.0F, 0.0F);
+    shaft_model.translate(0.0F, 0.0F, -arrow_length * 0.25F);
+    shaft_model.scale(arrow_width * 0.3F, arrow_height * 0.8F,
+                      arrow_length * 0.5F);
+
+    renderer->mesh(resources->unit(), shaft_model, arrow_main,
+                   resources->white(), 0.85F);
+  }
+
+  float const head_tip_z = -arrow_length * 0.55F;
+  float const stick_len = arrow_head_size * 0.7F;
+  float const stick_thickness = 0.05F;
+  float const stick_height = arrow_height * 0.8F;
+  float const head_angle_deg = 35.0F;
+
+  {
+    QMatrix4x4 head_left;
+    head_left.translate(placement.position.x(), base_y, placement.position.z());
+    head_left.rotate(visual_angle_degrees, 0.0F, 1.0F, 0.0F);
+    head_left.translate(-arrow_head_size * 0.22F, 0.0F, head_tip_z);
+    head_left.rotate(-head_angle_deg, 0.0F, 1.0F, 0.0F);
+
+    head_left.translate(0.0F, 0.0F, stick_len * 0.5F);
+    head_left.scale(stick_thickness, stick_height, stick_len);
+
+    renderer->mesh(resources->unit(), head_left, arrow_accent,
+                   resources->white(), 0.95F);
+  }
+
+  {
+    QMatrix4x4 head_right;
+    head_right.translate(placement.position.x(), base_y,
+                         placement.position.z());
+    head_right.rotate(visual_angle_degrees, 0.0F, 1.0F, 0.0F);
+    head_right.translate(arrow_head_size * 0.22F, 0.0F, head_tip_z);
+    head_right.rotate(head_angle_deg, 0.0F, 1.0F, 0.0F);
+    head_right.translate(0.0F, 0.0F, stick_len * 0.5F);
+    head_right.scale(stick_thickness, stick_height, stick_len);
+
+    renderer->mesh(resources->unit(), head_right, arrow_accent,
+                   resources->white(), 0.95F);
+  }
+
+  {
+    QMatrix4x4 edge_model;
+    edge_model.translate(placement.position.x(), base_y + arrow_height * 0.3F,
+                         placement.position.z());
+    edge_model.rotate(visual_angle_degrees, 0.0F, 1.0F, 0.0F);
+    edge_model.translate(arrow_width * 0.1F, 0.0F, -arrow_length * 0.2F);
+    edge_model.scale(0.05F, 0.04F, arrow_length * 0.45F);
+
+    renderer->mesh(resources->unit(), edge_model, arrow_accent,
+                   resources->white(), 0.6F);
+  }
+}
+
+} // namespace Render::GL

+ 22 - 0
render/geom/formation_arrow.h

@@ -0,0 +1,22 @@
+#pragma once
+
+#include <QVector3D>
+#include <optional>
+
+namespace Render::GL {
+class Renderer;
+class ResourceManager;
+} // namespace Render::GL
+
+namespace Render::GL {
+
+struct FormationPlacementInfo {
+  QVector3D position;
+  float angle_degrees = 0.0F;
+  bool active = false;
+};
+
+void renderFormationArrow(Renderer *renderer, ResourceManager *resources,
+                          const FormationPlacementInfo &placement);
+
+} // namespace Render::GL

+ 31 - 1
ui/qml/GameView.qml

@@ -9,6 +9,7 @@ Item {
     property real gameSpeed: 1
     property bool setRallyMode: false
     property string cursorMode: "normal"
+    property bool isPlacingFormation: false
     property var pressedKeys: ({
     })
 
@@ -31,7 +32,6 @@ Item {
     }
 
     function issueCommand(command) {
-        console.log("Command issued:", command);
     }
 
     function beginPanKey(e) {
@@ -243,6 +243,12 @@ Item {
 
             }
 
+            function onPlacing_formation_changed() {
+                if (typeof game !== 'undefined')
+                    gameView.isPlacingFormation = game.is_placing_formation;
+
+            }
+
             target: game
         }
 
@@ -282,10 +288,22 @@ Item {
                     if (typeof game !== 'undefined' && game.set_hover_at_screen)
                         game.set_hover_at_screen(mouse.x, mouse.y);
 
+                    if (gameView.isPlacingFormation) {
+                        if (typeof game !== 'undefined' && game.on_formation_mouse_move)
+                            game.on_formation_mouse_move(mouse.x, mouse.y);
+
+                    }
                 }
             }
             onWheel: function(w) {
                 var dy = (w.angleDelta ? w.angleDelta.y / 120 : w.delta / 120);
+                if (typeof game !== 'undefined' && game.is_placing_formation) {
+                    if (game.on_formation_scroll)
+                        game.on_formation_scroll(dy);
+
+                    w.accepted = true;
+                    return ;
+                }
                 if (dy !== 0 && typeof game !== 'undefined' && game.camera_zoom)
                     game.camera_zoom(dy * 0.8);
 
@@ -324,6 +342,12 @@ Item {
 
                         return ;
                     }
+                    if (typeof game !== 'undefined' && game.is_placing_formation) {
+                        if (game.on_formation_confirm)
+                            game.on_formation_confirm();
+
+                        return ;
+                    }
                     isSelecting = true;
                     startX = mouse.x;
                     startY = mouse.y;
@@ -333,6 +357,12 @@ Item {
                     selectionBox.height = 0;
                     selectionBox.visible = true;
                 } else if (mouse.button === Qt.RightButton) {
+                    if (typeof game !== 'undefined' && game.is_placing_formation) {
+                        if (game.on_formation_cancel)
+                            game.on_formation_cancel();
+
+                        return ;
+                    }
                     renderArea.mousePanActive = true;
                     mainWindow.edgeScrollDisabled = true;
                     if (gameView.setRallyMode)

+ 16 - 0
ui/qml/Main.qml

@@ -352,6 +352,22 @@ ApplicationWindow {
                     else
                         game.set_hover_at_screen(-1, -1);
                 }
+                if (typeof game !== 'undefined' && game.is_placing_formation && game.on_formation_mouse_move) {
+                    if (!edgeScrollOverlay.inHudZone(mouse.x, mouse.y))
+                        game.on_formation_mouse_move(mouse.x, mouse.y);
+
+                }
+            }
+            onWheel: function(w) {
+                if (typeof game !== 'undefined' && game.is_placing_formation && game.on_formation_scroll) {
+                    var dy = (w.angleDelta ? w.angleDelta.y / 120 : w.delta / 120);
+                    if (dy !== 0)
+                        game.on_formation_scroll(dy);
+
+                    w.accepted = true;
+                    return ;
+                }
+                w.accepted = false;
             }
             onEntered: function() {
                 edgeScrollTimer.start();