Browse Source

implement catapult

djeada 2 ngày trước cách đây
mục cha
commit
80e69e7cd7

+ 16 - 0
app/core/game_engine.cpp

@@ -59,9 +59,11 @@
 #include "game/map/world_bootstrap.h"
 #include "game/systems/ai_system.h"
 #include "game/systems/arrow_system.h"
+#include "game/systems/ballista_attack_system.h"
 #include "game/systems/building_collision_registry.h"
 #include "game/systems/camera_service.h"
 #include "game/systems/capture_system.h"
+#include "game/systems/catapult_attack_system.h"
 #include "game/systems/cleanup_system.h"
 #include "game/systems/combat_system.h"
 #include "game/systems/command_service.h"
@@ -77,6 +79,7 @@
 #include "game/systems/picking_service.h"
 #include "game/systems/production_service.h"
 #include "game/systems/production_system.h"
+#include "game/systems/projectile_system.h"
 #include "game/systems/save_load_service.h"
 #include "game/systems/selection_system.h"
 #include "game/systems/terrain_alignment_system.h"
@@ -86,6 +89,7 @@
 #include "game/units/troop_config.h"
 #include "render/geom/arrow.h"
 #include "render/geom/patrol_flags.h"
+#include "render/geom/stone.h"
 #include "render/gl/bootstrap.h"
 #include "render/gl/camera.h"
 #include "render/ground/biome_renderer.h"
@@ -148,9 +152,15 @@ GameEngine::GameEngine(QObject *parent)
       std::make_unique<Game::Systems::ArrowSystem>();
   m_world->add_system(std::move(arrow_sys));
 
+  std::unique_ptr<Engine::Core::System> projectile_sys =
+      std::make_unique<Game::Systems::ProjectileSystem>();
+  m_world->add_system(std::move(projectile_sys));
+
   m_world->add_system(std::make_unique<Game::Systems::MovementSystem>());
   m_world->add_system(std::make_unique<Game::Systems::PatrolSystem>());
   m_world->add_system(std::make_unique<Game::Systems::CombatSystem>());
+  m_world->add_system(std::make_unique<Game::Systems::CatapultAttackSystem>());
+  m_world->add_system(std::make_unique<Game::Systems::BallistaAttackSystem>());
   m_world->add_system(std::make_unique<Game::Systems::HealingSystem>());
   m_world->add_system(std::make_unique<Game::Systems::CaptureSystem>());
   m_world->add_system(std::make_unique<Game::Systems::AISystem>());
@@ -771,6 +781,12 @@ void GameEngine::render(int pixelWidth, int pixelHeight) {
       Render::GL::renderArrows(m_renderer.get(), res, *arrow_system);
     }
   }
+  if (auto *projectile_system =
+          m_world->get_system<Game::Systems::ProjectileSystem>()) {
+    if (auto *res = m_renderer->resources()) {
+      Render::GL::render_projectiles(m_renderer.get(), res, *projectile_system);
+    }
+  }
 
   if (auto *res = m_renderer->resources()) {
     std::optional<QVector3D> preview_waypoint;

+ 15 - 15
assets/data/nations/carthage.json

@@ -14,8 +14,8 @@
         "is_melee": false
       },
       "combat": {
-        "health": 75,
-        "max_health": 75,
+        "health": 675,
+        "max_health": 675,
         "speed": 3.2,
         "vision_range": 16.5,
         "ranged_range": 6.6,
@@ -49,8 +49,8 @@
         "is_melee": true
       },
       "combat": {
-        "health": 145,
-        "max_health": 145,
+        "health": 1305,
+        "max_health": 1305,
         "speed": 2.3,
         "vision_range": 14.0,
         "ranged_range": 1.5,
@@ -84,8 +84,8 @@
         "is_melee": true
       },
       "combat": {
-        "health": 125,
-        "max_health": 125,
+        "health": 1125,
+        "max_health": 1125,
         "speed": 2.6,
         "vision_range": 15.0,
         "ranged_range": 2.9,
@@ -119,8 +119,8 @@
         "is_melee": true
       },
       "combat": {
-        "health": 195,
-        "max_health": 195,
+        "health": 3120,
+        "max_health": 3120,
         "speed": 3.0,
         "vision_range": 18.0,
         "ranged_range": 1.5,
@@ -153,8 +153,8 @@
         "is_melee": false
       },
       "combat": {
-        "health": 95,
-        "max_health": 95,
+        "health": 855,
+        "max_health": 855,
         "speed": 2.7,
         "vision_range": 14.0,
         "ranged_range": 7.5,
@@ -168,7 +168,7 @@
       },
       "visuals": {
         "render_scale": 0.54,
-        "selection_ring_size": 1.2,
+        "selection_ring_size": 0.4,
         "selection_ring_y_offset": 0.0,
         "selection_ring_ground_offset": 0.0,
         "renderer_id": "troops/carthage/healer"
@@ -188,8 +188,8 @@
         "is_melee": false
       },
       "combat": {
-        "health": 140,
-        "max_health": 140,
+        "health": 2240,
+        "max_health": 2240,
         "speed": 3.0,
         "vision_range": 18.0,
         "ranged_range": 7.0,
@@ -222,8 +222,8 @@
         "is_melee": true
       },
       "combat": {
-        "health": 180,
-        "max_health": 180,
+        "health": 2880,
+        "max_health": 2880,
         "speed": 3.0,
         "vision_range": 16.0,
         "ranged_range": 1.5,

+ 15 - 15
assets/data/nations/roman_republic.json

@@ -14,8 +14,8 @@
         "is_melee": false
       },
       "combat": {
-        "health": 85,
-        "max_health": 85,
+        "health": 765,
+        "max_health": 765,
         "speed": 3.1,
         "vision_range": 17.0,
         "ranged_range": 6.4,
@@ -49,8 +49,8 @@
         "is_melee": true
       },
       "combat": {
-        "health": 170,
-        "max_health": 170,
+        "health": 1530,
+        "max_health": 1530,
         "speed": 2.1,
         "vision_range": 15.0,
         "ranged_range": 1.6,
@@ -84,8 +84,8 @@
         "is_melee": true
       },
       "combat": {
-        "health": 130,
-        "max_health": 130,
+        "health": 1170,
+        "max_health": 1170,
         "speed": 2.4,
         "vision_range": 15.5,
         "ranged_range": 2.7,
@@ -119,8 +119,8 @@
         "is_melee": true
       },
       "combat": {
-        "health": 210,
-        "max_health": 210,
+        "health": 3360,
+        "max_health": 3360,
         "speed": 4.5,
         "vision_range": 17.5,
         "ranged_range": 1.5,
@@ -153,8 +153,8 @@
         "is_melee": false
       },
       "combat": {
-        "health": 105,
-        "max_health": 105,
+        "health": 945,
+        "max_health": 945,
         "speed": 2.6,
         "vision_range": 14.5,
         "ranged_range": 8.5,
@@ -168,7 +168,7 @@
       },
       "visuals": {
         "render_scale": 0.56,
-        "selection_ring_size": 1.25,
+        "selection_ring_size": 0.4,
         "selection_ring_y_offset": 0.0,
         "selection_ring_ground_offset": 0.0,
         "renderer_id": "troops/roman/healer"
@@ -188,8 +188,8 @@
         "is_melee": false
       },
       "combat": {
-        "health": 140,
-        "max_health": 140,
+        "health": 2240,
+        "max_health": 2240,
         "speed": 3.0,
         "vision_range": 18.0,
         "ranged_range": 7.0,
@@ -222,8 +222,8 @@
         "is_melee": true
       },
       "combat": {
-        "health": 180,
-        "max_health": 180,
+        "health": 2880,
+        "max_health": 2880,
         "speed": 3.0,
         "vision_range": 16.0,
         "ranged_range": 1.5,

+ 14 - 14
assets/data/troops/base.json

@@ -10,8 +10,8 @@
         "is_melee": false
       },
       "combat": {
-        "health": 80,
-        "max_health": 80,
+        "health": 720,
+        "max_health": 720,
         "speed": 3.0,
         "vision_range": 16.0,
         "ranged_range": 6.0,
@@ -45,8 +45,8 @@
         "is_melee": true
       },
       "combat": {
-        "health": 140,
-        "max_health": 140,
+        "health": 1260,
+        "max_health": 1260,
         "speed": 2.1,
         "vision_range": 14.0,
         "ranged_range": 1.6,
@@ -80,8 +80,8 @@
         "is_melee": true
       },
       "combat": {
-        "health": 120,
-        "max_health": 120,
+        "health": 1080,
+        "max_health": 1080,
         "speed": 2.5,
         "vision_range": 15.0,
         "ranged_range": 2.5,
@@ -115,8 +115,8 @@
         "is_melee": true
       },
       "combat": {
-        "health": 200,
-        "max_health": 200,
+        "health": 3200,
+        "max_health": 3200,
         "speed": 3.0,
         "vision_range": 16.0,
         "ranged_range": 1.5,
@@ -149,8 +149,8 @@
         "is_melee": false
       },
       "combat": {
-        "health": 140,
-        "max_health": 140,
+        "health": 2240,
+        "max_health": 2240,
         "speed": 3.0,
         "vision_range": 18.0,
         "ranged_range": 7.0,
@@ -183,8 +183,8 @@
         "is_melee": true
       },
       "combat": {
-        "health": 180,
-        "max_health": 180,
+        "health": 2880,
+        "max_health": 2880,
         "speed": 3.0,
         "vision_range": 16.0,
         "ranged_range": 1.5,
@@ -217,8 +217,8 @@
         "is_melee": false
       },
       "combat": {
-        "health": 100,
-        "max_health": 100,
+        "health": 900,
+        "max_health": 900,
         "speed": 2.5,
         "vision_range": 14.0,
         "ranged_range": 8.0,

+ 31 - 7
assets/maps/map_rivers.json

@@ -127,16 +127,40 @@
       "playerId": 1
     },
     {
-      "type": "ballista",
-      "x": 27,
-      "z": 34,
+      "type": "catapult",
+      "x": 30,
+      "z": 42,
       "playerId": 1
     },
     {
-      "type": "catapult",
-      "x": 27,
-      "z": 34,
-      "playerId": 1
+      "type": "archer",
+      "x": 44,
+      "z": 29,
+      "playerId": 2
+    },
+    {
+      "type": "spearman",
+      "x": 44,
+      "z": 31,
+      "playerId": 2
+    },
+    {
+      "type": "spearman",
+      "x": 44,
+      "z": 32,
+      "playerId": 2
+    },
+    {
+      "type": "swordsman",
+      "x": 46,
+      "z": 30,
+      "playerId": 2
+    },
+    {
+      "type": "swordsman",
+      "x": 46,
+      "z": 31,
+      "playerId": 2
     },
     {
       "type": "barracks",

+ 5 - 0
game/CMakeLists.txt

@@ -33,6 +33,11 @@ add_library(game_systems STATIC
     systems/building_collision_registry.cpp
     systems/selection_system.cpp
     systems/arrow_system.cpp
+    systems/projectile_system.cpp
+    systems/arrow_projectile.cpp
+    systems/stone_projectile.cpp
+    systems/catapult_attack_system.cpp
+    systems/ballista_attack_system.cpp
     systems/camera_follow_system.cpp
     systems/camera_controller.cpp
     systems/camera_service.cpp

+ 45 - 0
game/core/component.h

@@ -240,4 +240,49 @@ public:
   float time_since_last_heal{0.0F};
 };
 
+class CatapultLoadingComponent : public Component {
+public:
+  enum class LoadingState { Idle, Loading, ReadyToFire, Firing };
+
+  CatapultLoadingComponent() = default;
+
+  LoadingState state{LoadingState::Idle};
+  float loading_time{0.0F};
+  float loading_duration{2.0F};
+  float firing_time{0.0F};
+  float firing_duration{0.5F};
+
+  EntityID target_id{0};
+  float target_locked_x{0.0F};
+  float target_locked_y{0.0F};
+  float target_locked_z{0.0F};
+  bool target_position_locked{false};
+
+  [[nodiscard]] auto get_loading_progress() const -> float {
+    if (loading_duration <= 0.0F) {
+      return 1.0F;
+    }
+    return std::min(loading_time / loading_duration, 1.0F);
+  }
+
+  [[nodiscard]] auto get_firing_progress() const -> float {
+    if (firing_duration <= 0.0F) {
+      return 1.0F;
+    }
+    return std::min(firing_time / firing_duration, 1.0F);
+  }
+
+  [[nodiscard]] auto is_loading() const -> bool {
+    return state == LoadingState::Loading;
+  }
+
+  [[nodiscard]] auto is_ready_to_fire() const -> bool {
+    return state == LoadingState::ReadyToFire;
+  }
+
+  [[nodiscard]] auto is_firing() const -> bool {
+    return state == LoadingState::Firing;
+  }
+};
+
 } // namespace Engine::Core

+ 25 - 0
game/systems/arrow_projectile.cpp

@@ -0,0 +1,25 @@
+#include "arrow_projectile.h"
+
+namespace Game::Systems {
+
+ArrowProjectile::ArrowProjectile(const QVector3D &start, const QVector3D &end,
+                                 const QVector3D &color, float speed,
+                                 float arc_height, float inv_dist,
+                                 bool is_ballista_bolt)
+    : m_start(start), m_end(end), m_color(color), m_speed(speed),
+      m_arc_height(arc_height), m_inv_dist(inv_dist),
+      m_is_ballista_bolt(is_ballista_bolt) {}
+
+void ArrowProjectile::update(float delta_time) {
+  if (!m_active) {
+    return;
+  }
+
+  m_t += delta_time * m_speed * m_inv_dist;
+  if (m_t >= 1.0F) {
+    m_t = 1.0F;
+    m_active = false;
+  }
+}
+
+} // namespace Game::Systems

+ 46 - 0
game/systems/arrow_projectile.h

@@ -0,0 +1,46 @@
+#pragma once
+#include "projectile.h"
+
+namespace Game::Systems {
+
+class ArrowProjectile : public Projectile {
+public:
+  ArrowProjectile(const QVector3D &start, const QVector3D &end,
+                  const QVector3D &color, float speed, float arc_height,
+                  float inv_dist, bool is_ballista_bolt = false);
+
+  auto get_start() const -> QVector3D override { return m_start; }
+  auto get_end() const -> QVector3D override { return m_end; }
+  auto get_color() const -> QVector3D override { return m_color; }
+  auto get_speed() const -> float override { return m_speed; }
+  auto get_arc_height() const -> float override { return m_arc_height; }
+  auto get_progress() const -> float override { return m_t; }
+  auto get_scale() const -> float override { return m_scale; }
+  auto is_active() const -> bool override { return m_active; }
+  auto is_ballista_bolt() const -> bool { return m_is_ballista_bolt; }
+
+  auto should_apply_damage() const -> bool override { return false; }
+  auto get_damage() const -> int override { return 0; }
+  auto get_target_id() const -> Engine::Core::EntityID override { return 0; }
+  auto get_attacker_id() const -> Engine::Core::EntityID override { return 0; }
+  auto get_target_locked_position() const -> QVector3D override {
+    return m_end;
+  }
+
+  void update(float delta_time) override;
+  void deactivate() override { m_active = false; }
+
+private:
+  QVector3D m_start;
+  QVector3D m_end;
+  QVector3D m_color;
+  float m_t{0.0F};
+  float m_speed{};
+  float m_arc_height{};
+  float m_inv_dist{};
+  float m_scale{1.0F};
+  bool m_active{true};
+  bool m_is_ballista_bolt{false};
+};
+
+} // namespace Game::Systems

+ 283 - 0
game/systems/ballista_attack_system.cpp

@@ -0,0 +1,283 @@
+#include "ballista_attack_system.h"
+#include "../core/component.h"
+#include "../core/event_manager.h"
+#include "../core/world.h"
+#include "../units/spawn_type.h"
+#include "../visuals/team_colors.h"
+#include "projectile_system.h"
+#include <cmath>
+#include <qvectornd.h>
+
+namespace Game::Systems {
+
+void BallistaAttackSystem::update(Engine::Core::World *world,
+                                  float delta_time) {
+  process_ballista_attacks(world, delta_time);
+}
+
+void BallistaAttackSystem::process_ballista_attacks(Engine::Core::World *world,
+                                                    float delta_time) {
+  auto entities = world->get_entities_with<Engine::Core::UnitComponent>();
+
+  for (auto *entity : entities) {
+    auto *unit = entity->get_component<Engine::Core::UnitComponent>();
+    if (unit == nullptr || unit->health <= 0) {
+      continue;
+    }
+
+    if (unit->spawn_type != Game::Units::SpawnType::Ballista) {
+      continue;
+    }
+
+    if (entity->has_component<Engine::Core::PendingRemovalComponent>()) {
+      continue;
+    }
+
+    auto *loading =
+        entity->get_component<Engine::Core::CatapultLoadingComponent>();
+    if (loading == nullptr) {
+      loading = entity->add_component<Engine::Core::CatapultLoadingComponent>();
+    }
+
+    auto *movement = entity->get_component<Engine::Core::MovementComponent>();
+    if (movement != nullptr) {
+      constexpr float k_movement_threshold = 0.01F;
+      bool is_moving = (std::abs(movement->vx) > k_movement_threshold ||
+                        std::abs(movement->vz) > k_movement_threshold);
+
+      if (is_moving &&
+          loading->state !=
+              Engine::Core::CatapultLoadingComponent::LoadingState::Idle) {
+
+        loading->state =
+            Engine::Core::CatapultLoadingComponent::LoadingState::Idle;
+        loading->loading_time = 0.0F;
+        loading->firing_time = 0.0F;
+        loading->target_position_locked = false;
+        loading->target_id = 0;
+      }
+    }
+
+    switch (loading->state) {
+    case Engine::Core::CatapultLoadingComponent::LoadingState::Idle: {
+
+      auto *attack_target =
+          entity->get_component<Engine::Core::AttackTargetComponent>();
+      if (attack_target != nullptr && attack_target->target_id != 0) {
+        auto *target = world->get_entity(attack_target->target_id);
+        if (target != nullptr &&
+            !target->has_component<Engine::Core::PendingRemovalComponent>()) {
+          auto *target_unit =
+              target->get_component<Engine::Core::UnitComponent>();
+          if (target_unit != nullptr && target_unit->health > 0) {
+
+            auto *transform =
+                entity->get_component<Engine::Core::TransformComponent>();
+            auto *target_transform =
+                target->get_component<Engine::Core::TransformComponent>();
+            auto *attack =
+                entity->get_component<Engine::Core::AttackComponent>();
+
+            if (transform != nullptr && target_transform != nullptr &&
+                attack != nullptr) {
+              float const dx =
+                  target_transform->position.x - transform->position.x;
+              float const dz =
+                  target_transform->position.z - transform->position.z;
+              float const dist = std::sqrt(dx * dx + dz * dz);
+
+              if (dist <= attack->range) {
+                start_loading(entity, target);
+              }
+            }
+          }
+        }
+      }
+      break;
+    }
+
+    case Engine::Core::CatapultLoadingComponent::LoadingState::Loading: {
+      update_loading(entity, delta_time);
+      break;
+    }
+
+    case Engine::Core::CatapultLoadingComponent::LoadingState::ReadyToFire: {
+      fire_projectile(world, entity);
+      break;
+    }
+
+    case Engine::Core::CatapultLoadingComponent::LoadingState::Firing: {
+      update_firing(entity, delta_time);
+      break;
+    }
+    }
+  }
+}
+
+void BallistaAttackSystem::start_loading(Engine::Core::Entity *ballista,
+                                         Engine::Core::Entity *target) {
+  auto *loading =
+      ballista->get_component<Engine::Core::CatapultLoadingComponent>();
+  if (loading == nullptr) {
+    return;
+  }
+
+  auto *target_transform =
+      target->get_component<Engine::Core::TransformComponent>();
+  if (target_transform == nullptr) {
+    return;
+  }
+
+  loading->state =
+      Engine::Core::CatapultLoadingComponent::LoadingState::Loading;
+  loading->loading_time = 0.0F;
+  loading->target_id = target->get_id();
+  loading->target_locked_x = target_transform->position.x;
+  loading->target_locked_y = target_transform->position.y;
+  loading->target_locked_z = target_transform->position.z;
+  loading->target_position_locked = true;
+
+  auto *ballista_transform =
+      ballista->get_component<Engine::Core::TransformComponent>();
+  if (ballista_transform != nullptr) {
+    float const dx =
+        target_transform->position.x - ballista_transform->position.x;
+    float const dz =
+        target_transform->position.z - ballista_transform->position.z;
+    float const yaw = std::atan2(dx, dz) * 180.0F / 3.14159265F;
+    ballista_transform->desired_yaw = yaw;
+    ballista_transform->has_desired_yaw = true;
+  }
+}
+
+void BallistaAttackSystem::update_loading(Engine::Core::Entity *ballista,
+                                          float delta_time) {
+  auto *loading =
+      ballista->get_component<Engine::Core::CatapultLoadingComponent>();
+  if (loading == nullptr) {
+    return;
+  }
+
+  constexpr float k_ballista_loading_duration = 1.0F;
+  loading->loading_duration = k_ballista_loading_duration;
+  loading->loading_time += delta_time;
+
+  if (loading->loading_time >= loading->loading_duration) {
+    loading->state =
+        Engine::Core::CatapultLoadingComponent::LoadingState::ReadyToFire;
+  }
+}
+
+void BallistaAttackSystem::fire_projectile(Engine::Core::World *world,
+                                           Engine::Core::Entity *ballista) {
+  auto *loading =
+      ballista->get_component<Engine::Core::CatapultLoadingComponent>();
+  if (loading == nullptr) {
+    return;
+  }
+
+  auto *projectile_sys = world->get_system<ProjectileSystem>();
+  if (projectile_sys == nullptr) {
+    loading->state = Engine::Core::CatapultLoadingComponent::LoadingState::Idle;
+    loading->loading_time = 0.0F;
+    loading->target_position_locked = false;
+    return;
+  }
+
+  auto *transform = ballista->get_component<Engine::Core::TransformComponent>();
+  auto *attack = ballista->get_component<Engine::Core::AttackComponent>();
+  auto *unit = ballista->get_component<Engine::Core::UnitComponent>();
+
+  if (transform == nullptr || attack == nullptr) {
+    loading->state = Engine::Core::CatapultLoadingComponent::LoadingState::Idle;
+    loading->loading_time = 0.0F;
+    loading->target_position_locked = false;
+    return;
+  }
+
+  QVector3D const start(transform->position.x, transform->position.y + 1.0F,
+                        transform->position.z);
+
+  QVector3D const end(loading->target_locked_x, loading->target_locked_y,
+                      loading->target_locked_z);
+
+  QVector3D color(0.8F, 0.7F, 0.2F);
+  if (unit != nullptr) {
+    color = Game::Visuals::team_colorForOwner(unit->owner_id);
+  }
+
+  constexpr float k_bolt_speed = 10.0F;
+  projectile_sys->spawn_arrow(start, end, color, k_bolt_speed, true);
+
+  auto *target = world->get_entity(loading->target_id);
+  if (target != nullptr) {
+    auto *target_unit = target->get_component<Engine::Core::UnitComponent>();
+    auto *target_transform =
+        target->get_component<Engine::Core::TransformComponent>();
+
+    if (target_unit != nullptr && target_transform != nullptr &&
+        attack != nullptr) {
+
+      QVector3D const current_pos(target_transform->position.x,
+                                  target_transform->position.y,
+                                  target_transform->position.z);
+      float const distance = (current_pos - QVector3D(loading->target_locked_x,
+                                                      loading->target_locked_y,
+                                                      loading->target_locked_z))
+                                 .length();
+
+      constexpr float k_escape_radius = 1.5F;
+      if (distance <= k_escape_radius) {
+
+        target_unit->health -= attack->damage;
+        if (target_unit->health <= 0) {
+          target_unit->health = 0;
+
+          int killer_owner_id = 0;
+          auto *ballista_unit =
+              ballista->get_component<Engine::Core::UnitComponent>();
+          if (ballista_unit != nullptr) {
+            killer_owner_id = ballista_unit->owner_id;
+          }
+
+          Engine::Core::EventManager::instance().publish(
+              Engine::Core::UnitDiedEvent(loading->target_id,
+                                          target_unit->owner_id,
+                                          target_unit->spawn_type,
+                                          ballista->get_id(), killer_owner_id));
+        }
+      }
+    }
+  }
+
+  loading->state = Engine::Core::CatapultLoadingComponent::LoadingState::Firing;
+  loading->firing_time = 0.0F;
+}
+
+void BallistaAttackSystem::update_firing(Engine::Core::Entity *ballista,
+                                         float delta_time) {
+  auto *loading =
+      ballista->get_component<Engine::Core::CatapultLoadingComponent>();
+  if (loading == nullptr) {
+    return;
+  }
+
+  constexpr float k_ballista_firing_duration = 0.3F;
+  loading->firing_duration = k_ballista_firing_duration;
+  loading->firing_time += delta_time;
+
+  if (loading->firing_time >= loading->firing_duration) {
+
+    loading->state = Engine::Core::CatapultLoadingComponent::LoadingState::Idle;
+    loading->loading_time = 0.0F;
+    loading->firing_time = 0.0F;
+    loading->target_position_locked = false;
+
+    auto *attack = ballista->get_component<Engine::Core::AttackComponent>();
+    if (attack != nullptr) {
+      attack->time_since_last = 0.0F;
+    }
+  }
+}
+
+} // namespace Game::Systems

+ 26 - 0
game/systems/ballista_attack_system.h

@@ -0,0 +1,26 @@
+#pragma once
+
+#include "../core/system.h"
+
+namespace Engine::Core {
+class World;
+class Entity;
+} // namespace Engine::Core
+
+namespace Game::Systems {
+
+class BallistaAttackSystem : public Engine::Core::System {
+public:
+  void update(Engine::Core::World *world, float delta_time) override;
+
+private:
+  void process_ballista_attacks(Engine::Core::World *world, float delta_time);
+  void start_loading(Engine::Core::Entity *ballista,
+                     Engine::Core::Entity *target);
+  void update_loading(Engine::Core::Entity *ballista, float delta_time);
+  void fire_projectile(Engine::Core::World *world,
+                       Engine::Core::Entity *ballista);
+  void update_firing(Engine::Core::Entity *ballista, float delta_time);
+};
+
+} // namespace Game::Systems

+ 241 - 0
game/systems/catapult_attack_system.cpp

@@ -0,0 +1,241 @@
+#include "catapult_attack_system.h"
+#include "../core/component.h"
+#include "../core/world.h"
+#include "../units/spawn_type.h"
+#include "../visuals/team_colors.h"
+#include "projectile_system.h"
+#include <cmath>
+#include <qvectornd.h>
+
+namespace Game::Systems {
+
+void CatapultAttackSystem::update(Engine::Core::World *world,
+                                  float delta_time) {
+  process_catapult_attacks(world, delta_time);
+}
+
+void CatapultAttackSystem::process_catapult_attacks(Engine::Core::World *world,
+                                                    float delta_time) {
+  auto entities = world->get_entities_with<Engine::Core::UnitComponent>();
+
+  for (auto *entity : entities) {
+    auto *unit = entity->get_component<Engine::Core::UnitComponent>();
+    if (unit == nullptr || unit->health <= 0) {
+      continue;
+    }
+
+    if (unit->spawn_type != Game::Units::SpawnType::Catapult) {
+      continue;
+    }
+
+    if (entity->has_component<Engine::Core::PendingRemovalComponent>()) {
+      continue;
+    }
+
+    auto *loading =
+        entity->get_component<Engine::Core::CatapultLoadingComponent>();
+    if (loading == nullptr) {
+      loading = entity->add_component<Engine::Core::CatapultLoadingComponent>();
+    }
+
+    auto *movement = entity->get_component<Engine::Core::MovementComponent>();
+    if (movement != nullptr) {
+      constexpr float k_movement_threshold = 0.01F;
+      bool is_moving = (std::abs(movement->vx) > k_movement_threshold ||
+                        std::abs(movement->vz) > k_movement_threshold);
+
+      if (is_moving &&
+          loading->state !=
+              Engine::Core::CatapultLoadingComponent::LoadingState::Idle) {
+
+        loading->state =
+            Engine::Core::CatapultLoadingComponent::LoadingState::Idle;
+        loading->loading_time = 0.0F;
+        loading->firing_time = 0.0F;
+        loading->target_position_locked = false;
+        loading->target_id = 0;
+      }
+    }
+
+    switch (loading->state) {
+    case Engine::Core::CatapultLoadingComponent::LoadingState::Idle: {
+
+      auto *attack_target =
+          entity->get_component<Engine::Core::AttackTargetComponent>();
+      if (attack_target != nullptr && attack_target->target_id != 0) {
+        auto *target = world->get_entity(attack_target->target_id);
+        if (target != nullptr &&
+            !target->has_component<Engine::Core::PendingRemovalComponent>()) {
+          auto *target_unit =
+              target->get_component<Engine::Core::UnitComponent>();
+          if (target_unit != nullptr && target_unit->health > 0) {
+
+            auto *transform =
+                entity->get_component<Engine::Core::TransformComponent>();
+            auto *target_transform =
+                target->get_component<Engine::Core::TransformComponent>();
+            auto *attack =
+                entity->get_component<Engine::Core::AttackComponent>();
+
+            if (transform != nullptr && target_transform != nullptr &&
+                attack != nullptr) {
+              float const dx =
+                  target_transform->position.x - transform->position.x;
+              float const dz =
+                  target_transform->position.z - transform->position.z;
+              float const dist = std::sqrt(dx * dx + dz * dz);
+
+              if (dist <= attack->range) {
+                start_loading(entity, target);
+              }
+            }
+          }
+        }
+      }
+      break;
+    }
+
+    case Engine::Core::CatapultLoadingComponent::LoadingState::Loading: {
+      update_loading(entity, delta_time);
+      break;
+    }
+
+    case Engine::Core::CatapultLoadingComponent::LoadingState::ReadyToFire: {
+      fire_projectile(world, entity);
+      break;
+    }
+
+    case Engine::Core::CatapultLoadingComponent::LoadingState::Firing: {
+      update_firing(entity, delta_time);
+      break;
+    }
+    }
+  }
+}
+
+void CatapultAttackSystem::start_loading(Engine::Core::Entity *catapult,
+                                         Engine::Core::Entity *target) {
+  auto *loading =
+      catapult->get_component<Engine::Core::CatapultLoadingComponent>();
+  if (loading == nullptr) {
+    return;
+  }
+
+  auto *target_transform =
+      target->get_component<Engine::Core::TransformComponent>();
+  if (target_transform == nullptr) {
+    return;
+  }
+
+  loading->state =
+      Engine::Core::CatapultLoadingComponent::LoadingState::Loading;
+  loading->loading_time = 0.0F;
+  loading->target_id = target->get_id();
+  loading->target_locked_x = target_transform->position.x;
+  loading->target_locked_y = target_transform->position.y;
+  loading->target_locked_z = target_transform->position.z;
+  loading->target_position_locked = true;
+
+  auto *catapult_transform =
+      catapult->get_component<Engine::Core::TransformComponent>();
+  if (catapult_transform != nullptr) {
+    float const dx =
+        target_transform->position.x - catapult_transform->position.x;
+    float const dz =
+        target_transform->position.z - catapult_transform->position.z;
+    float const yaw = std::atan2(dx, dz) * 180.0F / 3.14159265F;
+    catapult_transform->desired_yaw = yaw;
+    catapult_transform->has_desired_yaw = true;
+  }
+}
+
+void CatapultAttackSystem::update_loading(Engine::Core::Entity *catapult,
+                                          float delta_time) {
+  auto *loading =
+      catapult->get_component<Engine::Core::CatapultLoadingComponent>();
+  if (loading == nullptr) {
+    return;
+  }
+
+  loading->loading_time += delta_time;
+
+  if (loading->loading_time >= loading->loading_duration) {
+    loading->state =
+        Engine::Core::CatapultLoadingComponent::LoadingState::ReadyToFire;
+  }
+}
+
+void CatapultAttackSystem::fire_projectile(Engine::Core::World *world,
+                                           Engine::Core::Entity *catapult) {
+  auto *loading =
+      catapult->get_component<Engine::Core::CatapultLoadingComponent>();
+  if (loading == nullptr) {
+    return;
+  }
+
+  auto *projectile_sys = world->get_system<ProjectileSystem>();
+  if (projectile_sys == nullptr) {
+    loading->state = Engine::Core::CatapultLoadingComponent::LoadingState::Idle;
+    loading->loading_time = 0.0F;
+    loading->target_position_locked = false;
+    return;
+  }
+
+  auto *transform = catapult->get_component<Engine::Core::TransformComponent>();
+  auto *attack = catapult->get_component<Engine::Core::AttackComponent>();
+  auto *unit = catapult->get_component<Engine::Core::UnitComponent>();
+
+  if (transform == nullptr || attack == nullptr) {
+    loading->state = Engine::Core::CatapultLoadingComponent::LoadingState::Idle;
+    loading->loading_time = 0.0F;
+    loading->target_position_locked = false;
+    return;
+  }
+
+  QVector3D const start(transform->position.x, transform->position.y + 1.5F,
+                        transform->position.z);
+
+  QVector3D const end(loading->target_locked_x, loading->target_locked_y,
+                      loading->target_locked_z);
+
+  QVector3D color(0.45F, 0.42F, 0.38F);
+  if (unit != nullptr) {
+    color = Game::Visuals::team_colorForOwner(unit->owner_id);
+  }
+
+  constexpr float k_stone_speed = 8.0F;
+  constexpr float k_stone_scale = 1.5F;
+
+  projectile_sys->spawn_stone(start, end, color, k_stone_speed, k_stone_scale,
+                              true, attack->damage, catapult->get_id(),
+                              loading->target_id);
+
+  loading->state = Engine::Core::CatapultLoadingComponent::LoadingState::Firing;
+  loading->firing_time = 0.0F;
+}
+
+void CatapultAttackSystem::update_firing(Engine::Core::Entity *catapult,
+                                         float delta_time) {
+  auto *loading =
+      catapult->get_component<Engine::Core::CatapultLoadingComponent>();
+  if (loading == nullptr) {
+    return;
+  }
+
+  loading->firing_time += delta_time;
+
+  if (loading->firing_time >= loading->firing_duration) {
+
+    loading->state = Engine::Core::CatapultLoadingComponent::LoadingState::Idle;
+    loading->loading_time = 0.0F;
+    loading->firing_time = 0.0F;
+    loading->target_position_locked = false;
+
+    auto *attack = catapult->get_component<Engine::Core::AttackComponent>();
+    if (attack != nullptr) {
+      attack->time_since_last = 0.0F;
+    }
+  }
+}
+
+} // namespace Game::Systems

+ 26 - 0
game/systems/catapult_attack_system.h

@@ -0,0 +1,26 @@
+#pragma once
+
+#include "../core/system.h"
+
+namespace Engine::Core {
+class World;
+class Entity;
+} // namespace Engine::Core
+
+namespace Game::Systems {
+
+class CatapultAttackSystem : public Engine::Core::System {
+public:
+  void update(Engine::Core::World *world, float delta_time) override;
+
+private:
+  void process_catapult_attacks(Engine::Core::World *world, float delta_time);
+  void start_loading(Engine::Core::Entity *catapult,
+                     Engine::Core::Entity *target);
+  void update_loading(Engine::Core::Entity *catapult, float delta_time);
+  void fire_projectile(Engine::Core::World *world,
+                       Engine::Core::Entity *catapult);
+  void update_firing(Engine::Core::Entity *catapult, float delta_time);
+};
+
+} // namespace Game::Systems

+ 10 - 4
game/systems/combat_system.cpp

@@ -474,9 +474,15 @@ void CombatSystem::processAttacks(Engine::Core::World *world,
             best_target->get_component<Engine::Core::TransformComponent>();
         auto *att_u = attacker->get_component<Engine::Core::UnitComponent>();
 
-        if ((attacker_atk == nullptr) ||
-            attacker_atk->current_mode !=
-                Engine::Core::AttackComponent::CombatMode::Melee) {
+        bool const should_show_arrow_vfx =
+            (att_u != nullptr &&
+             att_u->spawn_type != Game::Units::SpawnType::Catapult &&
+             att_u->spawn_type != Game::Units::SpawnType::Ballista);
+
+        if (should_show_arrow_vfx &&
+            ((attacker_atk == nullptr) ||
+             attacker_atk->current_mode !=
+                 Engine::Core::AttackComponent::CombatMode::Melee)) {
           QVector3D const a_pos(att_t->position.x, att_t->position.y,
                                 att_t->position.z);
           QVector3D const t_pos(tgt_t->position.x, tgt_t->position.y,
@@ -967,4 +973,4 @@ auto CombatSystem::findNearestEnemy(Engine::Core::Entity *unit,
   return nearest_enemy;
 }
 
-} // namespace Game::Systems
+} // namespace Game::Systems

+ 33 - 0
game/systems/projectile.h

@@ -0,0 +1,33 @@
+#pragma once
+#include "../core/entity.h"
+#include <QVector3D>
+#include <memory>
+
+namespace Game::Systems {
+
+class Projectile {
+public:
+  virtual ~Projectile() = default;
+
+  virtual auto get_start() const -> QVector3D = 0;
+  virtual auto get_end() const -> QVector3D = 0;
+  virtual auto get_color() const -> QVector3D = 0;
+  virtual auto get_speed() const -> float = 0;
+  virtual auto get_arc_height() const -> float = 0;
+  virtual auto get_progress() const -> float = 0;
+  virtual auto get_scale() const -> float = 0;
+
+  virtual auto is_active() const -> bool = 0;
+  virtual void update(float delta_time) = 0;
+  virtual void deactivate() = 0;
+
+  virtual auto should_apply_damage() const -> bool = 0;
+  virtual auto get_damage() const -> int = 0;
+  virtual auto get_target_id() const -> Engine::Core::EntityID = 0;
+  virtual auto get_attacker_id() const -> Engine::Core::EntityID = 0;
+  virtual auto get_target_locked_position() const -> QVector3D = 0;
+};
+
+using ProjectilePtr = std::unique_ptr<Projectile>;
+
+} // namespace Game::Systems

+ 138 - 0
game/systems/projectile_system.cpp

@@ -0,0 +1,138 @@
+#include "projectile_system.h"
+#include "../core/component.h"
+#include "../core/event_manager.h"
+#include "../core/world.h"
+#include "arrow_projectile.h"
+#include "stone_projectile.h"
+#include <algorithm>
+#include <cmath>
+#include <qvectornd.h>
+
+namespace Game::Systems {
+
+ProjectileSystem::ProjectileSystem()
+    : m_arrow_config(GameConfig::instance().arrow()) {}
+
+void ProjectileSystem::spawn_arrow(const QVector3D &start, const QVector3D &end,
+                                   const QVector3D &color, float speed,
+                                   bool is_ballista_bolt) {
+  QVector3D const delta = end - start;
+  float const dist = delta.length();
+
+  float arc_height;
+  if (is_ballista_bolt) {
+
+    arc_height = std::clamp(m_arrow_config.arcHeightMultiplier * dist * 0.4F,
+                            m_arrow_config.arcHeightMin * 0.5F,
+                            m_arrow_config.arcHeightMax * 0.6F);
+  } else {
+
+    arc_height =
+        std::clamp(m_arrow_config.arcHeightMultiplier * dist,
+                   m_arrow_config.arcHeightMin, m_arrow_config.arcHeightMax);
+  }
+  float inv_dist = (dist > 0.001F) ? (1.0F / dist) : 1.0F;
+
+  m_projectiles.push_back(std::make_unique<ArrowProjectile>(
+      start, end, color, speed, arc_height, inv_dist, is_ballista_bolt));
+}
+
+void ProjectileSystem::spawn_stone(const QVector3D &start, const QVector3D &end,
+                                   const QVector3D &color, float speed,
+                                   float scale, bool should_apply_damage,
+                                   int damage,
+                                   Engine::Core::EntityID attacker_id,
+                                   Engine::Core::EntityID target_id) {
+  QVector3D const delta = end - start;
+  float const dist = delta.length();
+
+  constexpr float k_stone_arc_multiplier = 0.35F;
+  constexpr float k_stone_arc_min = 1.0F;
+  constexpr float k_stone_arc_max = 4.0F;
+  float arc_height = std::clamp(k_stone_arc_multiplier * dist, k_stone_arc_min,
+                                k_stone_arc_max);
+  float inv_dist = (dist > 0.001F) ? (1.0F / dist) : 1.0F;
+
+  m_projectiles.push_back(std::make_unique<StoneProjectile>(
+      start, end, color, speed, arc_height, inv_dist, scale,
+      should_apply_damage, damage, attacker_id, target_id));
+}
+
+void ProjectileSystem::update(Engine::Core::World *world, float delta_time) {
+  for (auto &projectile : m_projectiles) {
+    projectile->update(delta_time);
+
+    if (!projectile->is_active()) {
+      continue;
+    }
+
+    if (projectile->should_apply_damage() && world != nullptr) {
+      apply_impact_damage(world, projectile.get());
+    }
+  }
+
+  m_projectiles.erase(
+      std::remove_if(m_projectiles.begin(), m_projectiles.end(),
+                     [](const ProjectilePtr &p) { return !p->is_active(); }),
+      m_projectiles.end());
+}
+
+void ProjectileSystem::apply_impact_damage(Engine::Core::World *world,
+                                           const Projectile *projectile) {
+  if (projectile->get_target_id() == 0) {
+    return;
+  }
+
+  auto *target = world->get_entity(projectile->get_target_id());
+  if (target == nullptr) {
+    return;
+  }
+
+  if (target->has_component<Engine::Core::PendingRemovalComponent>()) {
+    return;
+  }
+
+  auto *target_unit = target->get_component<Engine::Core::UnitComponent>();
+  if (target_unit == nullptr || target_unit->health <= 0) {
+    return;
+  }
+
+  auto *target_transform =
+      target->get_component<Engine::Core::TransformComponent>();
+  if (target_transform != nullptr) {
+    QVector3D const current_pos(target_transform->position.x,
+                                target_transform->position.y,
+                                target_transform->position.z);
+    float const distance =
+        (current_pos - projectile->get_target_locked_position()).length();
+
+    constexpr float k_escape_radius = 1.5F;
+    if (distance > k_escape_radius) {
+      return;
+    }
+  }
+
+  target_unit->health -= projectile->get_damage();
+  if (target_unit->health <= 0) {
+    target_unit->health = 0;
+
+    int killer_owner_id = 0;
+    if (projectile->get_attacker_id() != 0 && world != nullptr) {
+      auto *attacker = world->get_entity(projectile->get_attacker_id());
+      if (attacker != nullptr) {
+        auto *attacker_unit =
+            attacker->get_component<Engine::Core::UnitComponent>();
+        if (attacker_unit != nullptr) {
+          killer_owner_id = attacker_unit->owner_id;
+        }
+      }
+    }
+
+    Engine::Core::EventManager::instance().publish(Engine::Core::UnitDiedEvent(
+        projectile->get_target_id(), target_unit->owner_id,
+        target_unit->spawn_type, projectile->get_attacker_id(),
+        killer_owner_id));
+  }
+}
+
+} // namespace Game::Systems

+ 40 - 0
game/systems/projectile_system.h

@@ -0,0 +1,40 @@
+#pragma once
+#include "../core/entity.h"
+#include "../core/system.h"
+#include "../core/world.h"
+#include "../game_config.h"
+#include "projectile.h"
+#include <QVector3D>
+#include <memory>
+#include <vector>
+
+namespace Game::Systems {
+
+class ProjectileSystem : public Engine::Core::System {
+public:
+  ProjectileSystem();
+  void update(Engine::Core::World *world, float delta_time) override;
+
+  void spawn_arrow(const QVector3D &start, const QVector3D &end,
+                   const QVector3D &color, float speed = 8.0F,
+                   bool is_ballista_bolt = false);
+
+  void spawn_stone(const QVector3D &start, const QVector3D &end,
+                   const QVector3D &color, float speed = 5.0F,
+                   float scale = 1.0F, bool should_apply_damage = false,
+                   int damage = 0, Engine::Core::EntityID attacker_id = 0,
+                   Engine::Core::EntityID target_id = 0);
+
+  [[nodiscard]] auto projectiles() const -> const std::vector<ProjectilePtr> & {
+    return m_projectiles;
+  }
+
+private:
+  void apply_impact_damage(Engine::Core::World *world,
+                           const Projectile *projectile);
+
+  std::vector<ProjectilePtr> m_projectiles;
+  ArrowConfig m_arrow_config;
+};
+
+} // namespace Game::Systems

+ 29 - 0
game/systems/stone_projectile.cpp

@@ -0,0 +1,29 @@
+#include "stone_projectile.h"
+
+namespace Game::Systems {
+
+StoneProjectile::StoneProjectile(const QVector3D &start, const QVector3D &end,
+                                 const QVector3D &color, float speed,
+                                 float arc_height, float inv_dist, float scale,
+                                 bool should_apply_damage, int damage,
+                                 Engine::Core::EntityID attacker_id,
+                                 Engine::Core::EntityID target_id)
+    : m_start(start), m_end(end), m_color(color), m_speed(speed),
+      m_arc_height(arc_height), m_inv_dist(inv_dist), m_scale(scale),
+      m_should_apply_damage(should_apply_damage), m_damage(damage),
+      m_target_id(target_id), m_attacker_id(attacker_id),
+      m_target_locked_position(end) {}
+
+void StoneProjectile::update(float delta_time) {
+  if (!m_active) {
+    return;
+  }
+
+  m_t += delta_time * m_speed * m_inv_dist;
+  if (m_t >= 1.0F) {
+    m_t = 1.0F;
+    m_active = false;
+  }
+}
+
+} // namespace Game::Systems

+ 58 - 0
game/systems/stone_projectile.h

@@ -0,0 +1,58 @@
+#pragma once
+#include "projectile.h"
+
+namespace Game::Systems {
+
+class StoneProjectile : public Projectile {
+public:
+  StoneProjectile(const QVector3D &start, const QVector3D &end,
+                  const QVector3D &color, float speed, float arc_height,
+                  float inv_dist, float scale = 1.0F,
+                  bool should_apply_damage = false, int damage = 0,
+                  Engine::Core::EntityID attacker_id = 0,
+                  Engine::Core::EntityID target_id = 0);
+
+  auto get_start() const -> QVector3D override { return m_start; }
+  auto get_end() const -> QVector3D override { return m_end; }
+  auto get_color() const -> QVector3D override { return m_color; }
+  auto get_speed() const -> float override { return m_speed; }
+  auto get_arc_height() const -> float override { return m_arc_height; }
+  auto get_progress() const -> float override { return m_t; }
+  auto get_scale() const -> float override { return m_scale; }
+  auto is_active() const -> bool override { return m_active; }
+
+  auto should_apply_damage() const -> bool override {
+    return m_should_apply_damage;
+  }
+  auto get_damage() const -> int override { return m_damage; }
+  auto get_target_id() const -> Engine::Core::EntityID override {
+    return m_target_id;
+  }
+  auto get_attacker_id() const -> Engine::Core::EntityID override {
+    return m_attacker_id;
+  }
+  auto get_target_locked_position() const -> QVector3D override {
+    return m_target_locked_position;
+  }
+
+  void update(float delta_time) override;
+  void deactivate() override { m_active = false; }
+
+private:
+  QVector3D m_start;
+  QVector3D m_end;
+  QVector3D m_color;
+  float m_t{0.0F};
+  float m_speed{};
+  float m_arc_height{};
+  float m_inv_dist{};
+  float m_scale{1.0F};
+  bool m_active{true};
+  bool m_should_apply_damage{};
+  int m_damage{};
+  Engine::Core::EntityID m_target_id{0};
+  Engine::Core::EntityID m_attacker_id{0};
+  QVector3D m_target_locked_position;
+};
+
+} // namespace Game::Systems

+ 2 - 0
render/CMakeLists.txt

@@ -77,6 +77,8 @@ add_library(render_gl STATIC
     geom/selection_ring.cpp
     geom/selection_disc.cpp
     geom/arrow.cpp
+    geom/stone.cpp
+    geom/projectile_renderer.cpp
     geom/flag.cpp
     geom/patrol_flags.cpp
     geom/transforms.cpp

+ 77 - 2
render/entity/nations/carthage/ballista_renderer.cpp

@@ -30,15 +30,64 @@ struct CarthageBallistaPalette {
   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 bolt{0.35F, 0.30F, 0.25F};
   QVector3D team{0.8F, 0.9F, 1.0F};
 };
 
+enum class BallistaAnimState { Idle, Loading, Firing, Resetting };
+
+struct BallistaAnimContext {
+  BallistaAnimState state{BallistaAnimState::Idle};
+  float loading_progress{0.0F};
+  float firing_progress{0.0F};
+  bool show_bolt{false};
+};
+
 inline auto make_palette(const QVector3D &team) -> CarthageBallistaPalette {
   CarthageBallistaPalette p;
   p.team = clampVec01(team);
   return p;
 }
 
+inline auto
+get_anim_context(const Engine::Core::Entity *entity) -> BallistaAnimContext {
+  BallistaAnimContext ctx;
+  if (entity == nullptr) {
+    return ctx;
+  }
+
+  auto *loading =
+      entity->get_component<Engine::Core::CatapultLoadingComponent>();
+  if (loading == nullptr) {
+    return ctx;
+  }
+
+  switch (loading->state) {
+  case Engine::Core::CatapultLoadingComponent::LoadingState::Idle:
+    ctx.state = BallistaAnimState::Idle;
+    ctx.show_bolt = false;
+    break;
+  case Engine::Core::CatapultLoadingComponent::LoadingState::Loading:
+    ctx.state = BallistaAnimState::Loading;
+    ctx.loading_progress = loading->get_loading_progress();
+    ctx.show_bolt = true;
+    break;
+  case Engine::Core::CatapultLoadingComponent::LoadingState::ReadyToFire:
+    ctx.state = BallistaAnimState::Firing;
+    ctx.loading_progress = 1.0F;
+    ctx.firing_progress = 0.0F;
+    ctx.show_bolt = true;
+    break;
+  case Engine::Core::CatapultLoadingComponent::LoadingState::Firing:
+    ctx.state = BallistaAnimState::Firing;
+    ctx.firing_progress = loading->get_firing_progress();
+    ctx.show_bolt = ctx.firing_progress < 0.2F;
+    break;
+  }
+
+  return ctx;
+}
+
 inline void draw_box(ISubmitter &out, Mesh *unit, Texture *white,
                      const QMatrix4x4 &model, const QVector3D &pos,
                      const QVector3D &size, const QVector3D &color) {
@@ -180,7 +229,8 @@ void drawBowstring(const DrawContext &p, ISubmitter &out, Texture *white,
 }
 
 void drawSlide(const DrawContext &p, ISubmitter &out, Mesh *unit,
-               Texture *white, const CarthageBallistaPalette &c) {
+               Texture *white, const CarthageBallistaPalette &c,
+               const BallistaAnimContext &anim_ctx) {
 
   QMatrix4x4 tilted = p.model;
   tilted.rotate(30.0F, 1.0F, 0.0F, 0.0F);
@@ -198,6 +248,30 @@ void drawSlide(const DrawContext &p, ISubmitter &out, Mesh *unit,
 
   draw_cyl(out, tilted, QVector3D(0.0F, 0.25F, -0.23F),
            QVector3D(0.0F, 0.25F, -0.14F), 0.011F, c.metal_iron, white);
+
+  float slide_offset = 0.0F;
+  switch (anim_ctx.state) {
+  case BallistaAnimState::Idle:
+    slide_offset = 0.0F;
+    break;
+  case BallistaAnimState::Loading:
+    slide_offset = anim_ctx.loading_progress * 0.33F;
+    break;
+  case BallistaAnimState::Firing:
+    slide_offset = 0.33F * (1.0F - anim_ctx.firing_progress);
+    break;
+  case BallistaAnimState::Resetting:
+    slide_offset = 0.0F;
+    break;
+  }
+
+  if (anim_ctx.show_bolt) {
+    QMatrix4x4 bolt_matrix = tilted;
+    bolt_matrix.translate(0.0F, 0.25F, -0.19F + slide_offset);
+    float const bolt_scale = 0.038F;
+    bolt_matrix.scale(bolt_scale, bolt_scale, 0.14F);
+    out.mesh(getUnitCube(), bolt_matrix, c.bolt, white, 1.0F);
+  }
 }
 
 void drawTriggerMechanism(const DrawContext &p, ISubmitter &out, Mesh *unit,
@@ -276,6 +350,7 @@ void register_ballista_renderer(EntityRendererRegistry &registry) {
         }
 
         CarthageBallistaPalette c = make_palette(team_color);
+        auto anim_ctx = get_anim_context(p.entity);
 
         DrawContext ctx = p;
         ctx.model = p.model;
@@ -286,7 +361,7 @@ void register_ballista_renderer(EntityRendererRegistry &registry) {
         drawTorsionBundles(ctx, out, unit, white, c);
         drawArms(ctx, out, unit, white, c);
         drawBowstring(ctx, out, white, c);
-        drawSlide(ctx, out, unit, white, c);
+        drawSlide(ctx, out, unit, white, c, anim_ctx);
         drawTriggerMechanism(ctx, out, unit, white, c);
         drawCarthageOrnaments(ctx, out, unit, white, c);
       });

+ 110 - 34
render/entity/nations/carthage/catapult_renderer.cpp

@@ -29,15 +29,64 @@ struct CarthageCatapultPalette {
   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 stone{0.55F, 0.52F, 0.48F};
   QVector3D team{0.8F, 0.9F, 1.0F};
 };
 
+enum class CatapultAnimState { Idle, Loading, Firing, Resetting };
+
+struct CatapultAnimContext {
+  CatapultAnimState state{CatapultAnimState::Idle};
+  float loading_progress{0.0F};
+  float firing_progress{0.0F};
+  bool show_stone{false};
+};
+
 inline auto make_palette(const QVector3D &team) -> CarthageCatapultPalette {
   CarthageCatapultPalette p;
   p.team = clampVec01(team);
   return p;
 }
 
+inline auto
+get_anim_context(const Engine::Core::Entity *entity) -> CatapultAnimContext {
+  CatapultAnimContext ctx;
+  if (entity == nullptr) {
+    return ctx;
+  }
+
+  auto *loading =
+      entity->get_component<Engine::Core::CatapultLoadingComponent>();
+  if (loading == nullptr) {
+    return ctx;
+  }
+
+  switch (loading->state) {
+  case Engine::Core::CatapultLoadingComponent::LoadingState::Idle:
+    ctx.state = CatapultAnimState::Idle;
+    ctx.show_stone = false;
+    break;
+  case Engine::Core::CatapultLoadingComponent::LoadingState::Loading:
+    ctx.state = CatapultAnimState::Loading;
+    ctx.loading_progress = loading->get_loading_progress();
+    ctx.show_stone = true;
+    break;
+  case Engine::Core::CatapultLoadingComponent::LoadingState::ReadyToFire:
+    ctx.state = CatapultAnimState::Firing;
+    ctx.loading_progress = 1.0F;
+    ctx.firing_progress = 0.0F;
+    ctx.show_stone = true;
+    break;
+  case Engine::Core::CatapultLoadingComponent::LoadingState::Firing:
+    ctx.state = CatapultAnimState::Firing;
+    ctx.firing_progress = loading->get_firing_progress();
+    ctx.show_stone = ctx.firing_progress < 0.3F;
+    break;
+  }
+
+  return ctx;
+}
+
 inline void draw_box(ISubmitter &out, Mesh *unit, Texture *white,
                      const QMatrix4x4 &model, const QVector3D &pos,
                      const QVector3D &size, const QVector3D &color) {
@@ -130,7 +179,7 @@ void drawWheels(const DrawContext &p, ISubmitter &out, Mesh *unit,
 
 void drawThrowingArm(const DrawContext &p, ISubmitter &out, Mesh *unit,
                      Texture *white, const CarthageCatapultPalette &c,
-                     float animTime) {
+                     const CatapultAnimContext &anim_ctx) {
 
   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);
@@ -143,7 +192,24 @@ void drawThrowingArm(const DrawContext &p, ISubmitter &out, Mesh *unit,
   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;
+  float arm_angle = 0.75F;
+
+  switch (anim_ctx.state) {
+  case CatapultAnimState::Idle:
+    arm_angle = 0.75F;
+    break;
+  case CatapultAnimState::Loading:
+    arm_angle = 0.75F + anim_ctx.loading_progress * 0.55F;
+    break;
+  case CatapultAnimState::Firing:
+    arm_angle = 1.30F - anim_ctx.firing_progress * 1.9F;
+    arm_angle = std::max(arm_angle, -0.35F);
+    break;
+  case CatapultAnimState::Resetting:
+    arm_angle = 0.75F;
+    break;
+  }
+
   QMatrix4x4 armMatrix = p.model;
   armMatrix.translate(0.0F, 0.60F, 0.03F);
   armMatrix.rotate(arm_angle * 57.3F, 1.0F, 0.0F, 0.0F);
@@ -156,6 +222,15 @@ void drawThrowingArm(const DrawContext &p, ISubmitter &out, Mesh *unit,
 
   draw_box(out, unit, white, armMatrix, QVector3D(0.0F, 0.0F, 0.30F),
            QVector3D(0.08F, 0.08F, 0.08F), c.metal_bronze);
+
+  if (anim_ctx.show_stone) {
+    QMatrix4x4 stone_matrix = armMatrix;
+    stone_matrix.translate(0.0F, 0.10F, -0.58F);
+    float const stone_scale = 0.09F;
+    stone_matrix.scale(stone_scale, stone_scale, stone_scale);
+
+    out.mesh(getUnitCube(), stone_matrix, c.stone, white, 1.0F);
+  }
 }
 
 void drawTorsionMechanism(const DrawContext &p, ISubmitter &out, Mesh *unit,
@@ -218,38 +293,39 @@ void drawWindlass(const DrawContext &p, ISubmitter &out, Mesh *unit,
 } // 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->get_mesh_cube();
-      white_tex = scene_renderer->get_white_texture();
-    }
-
-    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->get_component<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.animation_time);
-    drawWindlass(p, out, unit_cube, white_tex, palette);
-    drawDecorations(p, out, unit_cube, white_tex, palette);
-  });
+  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->get_mesh_cube();
+          white_tex = scene_renderer->get_white_texture();
+        }
+
+        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
+                      ->get_component<Engine::Core::RenderableComponent>()) {
+            team_color = QVector3D(r->color[0], r->color[1], r->color[2]);
+          }
+        }
+
+        auto palette = make_palette(team_color);
+        auto anim_ctx = get_anim_context(p.entity);
+
+        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, anim_ctx);
+        drawWindlass(p, out, unit_cube, white_tex, palette);
+        drawDecorations(p, out, unit_cube, white_tex, palette);
+      });
 }
 
 } // namespace Render::GL::Carthage

+ 77 - 2
render/entity/nations/roman/ballista_renderer.cpp

@@ -28,15 +28,64 @@ struct RomanBallistaPalette {
   QVector3D metal_bronze{0.72F, 0.52F, 0.30F};
   QVector3D rope{0.62F, 0.55F, 0.42F};
   QVector3D leather{0.42F, 0.30F, 0.20F};
+  QVector3D bolt{0.35F, 0.30F, 0.25F};
   QVector3D team{0.8F, 0.9F, 1.0F};
 };
 
+enum class BallistaAnimState { Idle, Loading, Firing, Resetting };
+
+struct BallistaAnimContext {
+  BallistaAnimState state{BallistaAnimState::Idle};
+  float loading_progress{0.0F};
+  float firing_progress{0.0F};
+  bool show_bolt{false};
+};
+
 inline auto make_palette(const QVector3D &team) -> RomanBallistaPalette {
   RomanBallistaPalette p;
   p.team = clampVec01(team);
   return p;
 }
 
+inline auto
+get_anim_context(const Engine::Core::Entity *entity) -> BallistaAnimContext {
+  BallistaAnimContext ctx;
+  if (entity == nullptr) {
+    return ctx;
+  }
+
+  auto *loading =
+      entity->get_component<Engine::Core::CatapultLoadingComponent>();
+  if (loading == nullptr) {
+    return ctx;
+  }
+
+  switch (loading->state) {
+  case Engine::Core::CatapultLoadingComponent::LoadingState::Idle:
+    ctx.state = BallistaAnimState::Idle;
+    ctx.show_bolt = false;
+    break;
+  case Engine::Core::CatapultLoadingComponent::LoadingState::Loading:
+    ctx.state = BallistaAnimState::Loading;
+    ctx.loading_progress = loading->get_loading_progress();
+    ctx.show_bolt = true;
+    break;
+  case Engine::Core::CatapultLoadingComponent::LoadingState::ReadyToFire:
+    ctx.state = BallistaAnimState::Firing;
+    ctx.loading_progress = 1.0F;
+    ctx.firing_progress = 0.0F;
+    ctx.show_bolt = true;
+    break;
+  case Engine::Core::CatapultLoadingComponent::LoadingState::Firing:
+    ctx.state = BallistaAnimState::Firing;
+    ctx.firing_progress = loading->get_firing_progress();
+    ctx.show_bolt = ctx.firing_progress < 0.2F;
+    break;
+  }
+
+  return ctx;
+}
+
 inline void draw_box(ISubmitter &out, Mesh *unit, Texture *white,
                      const QMatrix4x4 &model, const QVector3D &pos,
                      const QVector3D &size, const QVector3D &color) {
@@ -170,7 +219,8 @@ void drawBowstring(const DrawContext &p, ISubmitter &out, Texture *white,
 }
 
 void drawSlide(const DrawContext &p, ISubmitter &out, Mesh *unit,
-               Texture *white, const RomanBallistaPalette &c) {
+               Texture *white, const RomanBallistaPalette &c,
+               const BallistaAnimContext &anim_ctx) {
 
   QMatrix4x4 tilted = p.model;
   tilted.rotate(30.0F, 1.0F, 0.0F, 0.0F);
@@ -188,6 +238,30 @@ void drawSlide(const DrawContext &p, ISubmitter &out, Mesh *unit,
 
   draw_cyl(out, tilted, QVector3D(0.0F, 0.26F, -0.25F),
            QVector3D(0.0F, 0.26F, -0.15F), 0.012F, c.metal_iron, white);
+
+  float slide_offset = 0.0F;
+  switch (anim_ctx.state) {
+  case BallistaAnimState::Idle:
+    slide_offset = 0.0F;
+    break;
+  case BallistaAnimState::Loading:
+    slide_offset = anim_ctx.loading_progress * 0.35F;
+    break;
+  case BallistaAnimState::Firing:
+    slide_offset = 0.35F * (1.0F - anim_ctx.firing_progress);
+    break;
+  case BallistaAnimState::Resetting:
+    slide_offset = 0.0F;
+    break;
+  }
+
+  if (anim_ctx.show_bolt) {
+    QMatrix4x4 bolt_matrix = tilted;
+    bolt_matrix.translate(0.0F, 0.26F, -0.20F + slide_offset);
+    float const bolt_scale = 0.04F;
+    bolt_matrix.scale(bolt_scale, bolt_scale, 0.15F);
+    out.mesh(getUnitCube(), bolt_matrix, c.bolt, white, 1.0F);
+  }
 }
 
 void drawTriggerMechanism(const DrawContext &p, ISubmitter &out, Mesh *unit,
@@ -257,6 +331,7 @@ void register_ballista_renderer(EntityRendererRegistry &registry) {
       }
     }
     RomanBallistaPalette c = make_palette(team_color);
+    auto anim_ctx = get_anim_context(p.entity);
 
     DrawContext ctx = p;
     ctx.model = p.model;
@@ -267,7 +342,7 @@ void register_ballista_renderer(EntityRendererRegistry &registry) {
     drawTorsionBundles(ctx, out, unit, white, c);
     drawArms(ctx, out, unit, white, c);
     drawBowstring(ctx, out, white, c);
-    drawSlide(ctx, out, unit, white, c);
+    drawSlide(ctx, out, unit, white, c, anim_ctx);
     drawTriggerMechanism(ctx, out, unit, white, c);
     drawRomanOrnaments(ctx, out, unit, white, c);
   });

+ 81 - 3
render/entity/nations/roman/catapult_renderer.cpp

@@ -28,15 +28,64 @@ struct RomanCatapultPalette {
   QVector3D metal_bronze{0.72F, 0.52F, 0.30F};
   QVector3D rope{0.62F, 0.55F, 0.42F};
   QVector3D leather{0.42F, 0.30F, 0.20F};
+  QVector3D stone{0.55F, 0.52F, 0.48F};
   QVector3D team{0.8F, 0.9F, 1.0F};
 };
 
+enum class CatapultAnimState { Idle, Loading, Firing, Resetting };
+
+struct CatapultAnimContext {
+  CatapultAnimState state{CatapultAnimState::Idle};
+  float loading_progress{0.0F};
+  float firing_progress{0.0F};
+  bool show_stone{false};
+};
+
 inline auto make_palette(const QVector3D &team) -> RomanCatapultPalette {
   RomanCatapultPalette p;
   p.team = clampVec01(team);
   return p;
 }
 
+inline auto
+get_anim_context(const Engine::Core::Entity *entity) -> CatapultAnimContext {
+  CatapultAnimContext ctx;
+  if (entity == nullptr) {
+    return ctx;
+  }
+
+  auto *loading =
+      entity->get_component<Engine::Core::CatapultLoadingComponent>();
+  if (loading == nullptr) {
+    return ctx;
+  }
+
+  switch (loading->state) {
+  case Engine::Core::CatapultLoadingComponent::LoadingState::Idle:
+    ctx.state = CatapultAnimState::Idle;
+    ctx.show_stone = false;
+    break;
+  case Engine::Core::CatapultLoadingComponent::LoadingState::Loading:
+    ctx.state = CatapultAnimState::Loading;
+    ctx.loading_progress = loading->get_loading_progress();
+    ctx.show_stone = true;
+    break;
+  case Engine::Core::CatapultLoadingComponent::LoadingState::ReadyToFire:
+    ctx.state = CatapultAnimState::Firing;
+    ctx.loading_progress = 1.0F;
+    ctx.firing_progress = 0.0F;
+    ctx.show_stone = true;
+    break;
+  case Engine::Core::CatapultLoadingComponent::LoadingState::Firing:
+    ctx.state = CatapultAnimState::Firing;
+    ctx.firing_progress = loading->get_firing_progress();
+    ctx.show_stone = ctx.firing_progress < 0.3F;
+    break;
+  }
+
+  return ctx;
+}
+
 inline void draw_box(ISubmitter &out, Mesh *unit, Texture *white,
                      const QMatrix4x4 &model, const QVector3D &pos,
                      const QVector3D &size, const QVector3D &color) {
@@ -125,7 +174,7 @@ void drawWheels(const DrawContext &p, ISubmitter &out, Mesh *unit,
 
 void drawThrowingArm(const DrawContext &p, ISubmitter &out, Mesh *unit,
                      Texture *white, const RomanCatapultPalette &c,
-                     float animTime) {
+                     const CatapultAnimContext &anim_ctx) {
 
   draw_cyl(out, p.model, QVector3D(-0.25F, 0.2F, 0.0F),
            QVector3D(-0.25F, 0.65F, 0.0F), 0.05F, c.wood_frame, white);
@@ -135,7 +184,26 @@ void drawThrowingArm(const DrawContext &p, ISubmitter &out, Mesh *unit,
   draw_cyl(out, p.model, QVector3D(-0.28F, 0.62F, 0.0F),
            QVector3D(0.28F, 0.62F, 0.0F), 0.04F, c.wood_dark, white);
 
-  float arm_angle = std::sin(animTime * 0.5F) * 0.3F + 0.8F;
+  float arm_angle = 0.8F;
+
+  switch (anim_ctx.state) {
+  case CatapultAnimState::Idle:
+    arm_angle = 0.8F;
+    break;
+  case CatapultAnimState::Loading:
+
+    arm_angle = 0.8F + anim_ctx.loading_progress * 0.6F;
+    break;
+  case CatapultAnimState::Firing:
+
+    arm_angle = 1.4F - anim_ctx.firing_progress * 2.0F;
+    arm_angle = std::max(arm_angle, -0.3F);
+    break;
+  case CatapultAnimState::Resetting:
+    arm_angle = 0.8F;
+    break;
+  }
+
   QMatrix4x4 armMatrix = p.model;
   armMatrix.translate(0.0F, 0.55F, 0.0F);
   armMatrix.rotate(arm_angle * 57.3F, 1.0F, 0.0F, 0.0F);
@@ -145,6 +213,15 @@ void drawThrowingArm(const DrawContext &p, ISubmitter &out, Mesh *unit,
 
   draw_box(out, unit, white, armMatrix, QVector3D(0.0F, -0.05F, -0.55F),
            QVector3D(0.08F, 0.06F, 0.10F), c.leather);
+
+  if (anim_ctx.show_stone) {
+    QMatrix4x4 stone_matrix = armMatrix;
+    stone_matrix.translate(0.0F, 0.08F, -0.55F);
+    float const stone_scale = 0.08F;
+    stone_matrix.scale(stone_scale, stone_scale, stone_scale);
+
+    out.mesh(getUnitCube(), stone_matrix, c.stone, white, 1.0F);
+  }
 }
 
 void drawTorsionMechanism(const DrawContext &p, ISubmitter &out, Mesh *unit,
@@ -226,11 +303,12 @@ void register_catapult_renderer(EntityRendererRegistry &registry) {
     }
 
     auto palette = make_palette(team_color);
+    auto anim_ctx = get_anim_context(p.entity);
 
     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.animation_time);
+    drawThrowingArm(p, out, unit_cube, white_tex, palette, anim_ctx);
     drawWindlass(p, out, unit_cube, white_tex, palette);
     drawDecorations(p, out, unit_cube, white_tex, palette);
   });

+ 201 - 0
render/geom/projectile_renderer.cpp

@@ -0,0 +1,201 @@
+#include "projectile_renderer.h"
+#include "../game/systems/arrow_projectile.h"
+#include "../game/systems/projectile_system.h"
+#include "../game/systems/stone_projectile.h"
+#include "../gl/resources.h"
+#include "../scene_renderer.h"
+#include "stone.h"
+#include <algorithm>
+#include <cmath>
+#include <numbers>
+
+namespace Render::GL {
+
+void render_arrow_projectile(Renderer *renderer, ResourceManager *resources,
+                             const Game::Systems::ArrowProjectile &arrow,
+                             const QVector3D &pos,
+                             const QMatrix4x4 &base_model) {
+  if (!renderer || !resources) {
+    return;
+  }
+
+  auto *arrow_mesh = ResourceManager::arrow();
+  if (!arrow_mesh) {
+    return;
+  }
+
+  const QVector3D delta = arrow.get_end() - arrow.get_start();
+  const float dist = std::max(0.001F, delta.length());
+
+  QMatrix4x4 model = base_model;
+
+  constexpr float k_arc_height_multiplier = 8.0F;
+  constexpr float k_arc_center_offset = 0.5F;
+  float const vy = (arrow.get_end().y() - arrow.get_start().y()) / dist;
+  float const pitch_deg =
+      -std::atan2(vy - (k_arc_height_multiplier * arrow.get_arc_height() *
+                        (arrow.get_progress() - k_arc_center_offset) / dist),
+                  1.0F) *
+      (180.0F / std::numbers::pi_v<float>);
+  model.rotate(pitch_deg, QVector3D(1, 0, 0));
+
+  if (arrow.is_ballista_bolt()) {
+
+    float const spin_speed = 1440.0F;
+    float const spin_angle = arrow.get_progress() * spin_speed;
+    model.rotate(spin_angle, QVector3D(0, 0, 1));
+
+    constexpr float bolt_z_scale = 0.85F;
+    constexpr float bolt_xy_scale = 0.48F;
+    constexpr float bolt_z_translate_factor = 0.5F;
+
+    QMatrix4x4 bolt_model = model;
+    bolt_model.translate(0.0F, 0.0F, -bolt_z_scale * bolt_z_translate_factor);
+    bolt_model.scale(bolt_xy_scale, bolt_xy_scale, bolt_z_scale);
+
+    QVector3D base_color = arrow.get_color();
+
+    QVector3D wood_color =
+        QVector3D(std::clamp(base_color.x() * 0.6F + 0.35F, 0.0F, 1.0F),
+                  std::clamp(base_color.y() * 0.55F + 0.30F, 0.0F, 1.0F),
+                  std::clamp(base_color.z() * 0.5F + 0.15F, 0.0F, 1.0F));
+
+    renderer->mesh(arrow_mesh, bolt_model, wood_color, nullptr, 1.0F);
+
+    QMatrix4x4 tip_model = bolt_model;
+
+    tip_model.translate(0.0F, 0.0F, bolt_z_scale * 0.3F);
+    tip_model.scale(0.85F, 0.85F, 0.2F);
+
+    QVector3D iron_color = QVector3D(0.25F, 0.24F, 0.22F);
+    renderer->mesh(arrow_mesh, tip_model, iron_color, nullptr, 1.0F);
+
+    QMatrix4x4 fletch_model = bolt_model;
+    fletch_model.translate(0.0F, 0.0F, -bolt_z_scale * 0.2F);
+    fletch_model.scale(0.75F, 0.75F, 0.15F);
+
+    QVector3D fletch_color =
+        QVector3D(std::clamp(wood_color.x() * 1.15F, 0.0F, 1.0F),
+                  std::clamp(wood_color.y() * 1.10F, 0.0F, 1.0F),
+                  std::clamp(wood_color.z() * 0.95F, 0.0F, 1.0F));
+    renderer->mesh(arrow_mesh, fletch_model, fletch_color, nullptr, 0.7F);
+
+    if (arrow.get_progress() > 0.15F) {
+      float trail_opacity =
+          std::clamp((arrow.get_progress() - 0.15F) / 0.85F, 0.0F, 0.3F);
+
+      for (int trail_idx = 1; trail_idx <= 2; trail_idx++) {
+        float trail_t = arrow.get_progress() - (trail_idx * 0.08F);
+        if (trail_t >= 0.0F) {
+          QVector3D trail_pos = arrow.get_start() + delta * trail_t;
+          float trail_h =
+              arrow.get_arc_height() * 4.0F * trail_t * (1.0F - trail_t);
+          trail_pos.setY(trail_pos.y() + trail_h);
+
+          QMatrix4x4 trail_model;
+          trail_model.translate(trail_pos.x(), trail_pos.y(), trail_pos.z());
+
+          constexpr float k_rad_to_deg = 180.0F / std::numbers::pi_v<float>;
+          QVector3D const dir = delta.normalized();
+          float const yaw_deg = std::atan2(dir.x(), dir.z()) * k_rad_to_deg;
+          trail_model.rotate(yaw_deg, QVector3D(0, 1, 0));
+          trail_model.rotate(pitch_deg, QVector3D(1, 0, 0));
+
+          float trail_spin_angle = trail_t * spin_speed;
+          trail_model.rotate(trail_spin_angle, QVector3D(0, 0, 1));
+
+          float trail_scale_factor = 0.6F - (trail_idx * 0.15F);
+          trail_model.translate(0.0F, 0.0F,
+                                -bolt_z_scale * bolt_z_translate_factor);
+          trail_model.scale(bolt_xy_scale * trail_scale_factor,
+                            bolt_xy_scale * trail_scale_factor,
+                            bolt_z_scale * trail_scale_factor);
+
+          QVector3D trail_color = wood_color * (1.0F - trail_opacity * 0.4F);
+          renderer->mesh(arrow_mesh, trail_model, trail_color, nullptr,
+                         1.0F - (trail_opacity * 0.7F));
+        }
+      }
+    }
+  } else {
+
+    constexpr float arrow_z_scale = 0.40F;
+    constexpr float arrow_xy_scale = 0.26F;
+    constexpr float arrow_z_translate_factor = 0.5F;
+    model.translate(0.0F, 0.0F, -arrow_z_scale * arrow_z_translate_factor);
+    model.scale(arrow_xy_scale, arrow_xy_scale, arrow_z_scale);
+    renderer->mesh(arrow_mesh, model, arrow.get_color(), nullptr, 1.0F);
+  }
+}
+
+void render_stone_projectile(Renderer *renderer, ResourceManager *resources,
+                             const Game::Systems::StoneProjectile &stone,
+                             const QVector3D &pos,
+                             const QMatrix4x4 &base_model) {
+  if (!renderer || !resources) {
+    return;
+  }
+
+  auto *stone_mesh = Geom::Stone::get();
+  if (!stone_mesh) {
+    return;
+  }
+
+  QMatrix4x4 model = base_model;
+
+  float const tumble_speed = 720.0F;
+  float const tumble_angle = stone.get_progress() * tumble_speed;
+  model.rotate(tumble_angle, QVector3D(1, 0.5F, 0.3F).normalized());
+
+  float const stone_scale = stone.get_scale();
+  model.scale(stone_scale, stone_scale, stone_scale);
+
+  QVector3D const stone_color(0.45F, 0.42F, 0.38F);
+  renderer->mesh(stone_mesh, model, stone_color, nullptr, 1.0F);
+}
+
+void render_projectiles(
+    Renderer *renderer, ResourceManager *resources,
+    const Game::Systems::ProjectileSystem &projectile_system) {
+  if (!renderer || !resources) {
+    return;
+  }
+
+  const auto &projectiles = projectile_system.projectiles();
+
+  constexpr float k_rad_to_deg = 180.0F / std::numbers::pi_v<float>;
+
+  for (const auto &projectile : projectiles) {
+    if (!projectile->is_active()) {
+      continue;
+    }
+
+    const QVector3D delta = projectile->get_end() - projectile->get_start();
+    const float dist = std::max(0.001F, delta.length());
+    QVector3D pos =
+        projectile->get_start() + delta * projectile->get_progress();
+
+    float const h = projectile->get_arc_height() * 4.0F *
+                    projectile->get_progress() *
+                    (1.0F - projectile->get_progress());
+    pos.setY(pos.y() + h);
+
+    QMatrix4x4 model;
+    model.translate(pos.x(), pos.y(), pos.z());
+
+    QVector3D const dir = delta.normalized();
+    float const yaw_deg = std::atan2(dir.x(), dir.z()) * k_rad_to_deg;
+    model.rotate(yaw_deg, QVector3D(0, 1, 0));
+
+    if (auto *arrow = dynamic_cast<const Game::Systems::ArrowProjectile *>(
+            projectile.get())) {
+      render_arrow_projectile(renderer, resources, *arrow, pos, model);
+    } else if (auto *stone =
+                   dynamic_cast<const Game::Systems::StoneProjectile *>(
+                       projectile.get())) {
+      render_stone_projectile(renderer, resources, *stone, pos, model);
+    }
+  }
+}
+
+} // namespace Render::GL

+ 35 - 0
render/geom/projectile_renderer.h

@@ -0,0 +1,35 @@
+#pragma once
+#include <QMatrix4x4>
+#include <QVector3D>
+
+namespace Render {
+namespace GL {
+class Renderer;
+class ResourceManager;
+} // namespace GL
+} // namespace Render
+
+namespace Game::Systems {
+class ProjectileSystem;
+class Projectile;
+class ArrowProjectile;
+class StoneProjectile;
+} // namespace Game::Systems
+
+namespace Render::GL {
+
+void render_projectiles(
+    Renderer *renderer, ResourceManager *resources,
+    const Game::Systems::ProjectileSystem &projectile_system);
+
+void render_arrow_projectile(Renderer *renderer, ResourceManager *resources,
+                             const Game::Systems::ArrowProjectile &arrow,
+                             const QVector3D &pos,
+                             const QMatrix4x4 &base_model);
+
+void render_stone_projectile(Renderer *renderer, ResourceManager *resources,
+                             const Game::Systems::StoneProjectile &stone,
+                             const QVector3D &pos,
+                             const QMatrix4x4 &base_model);
+
+} // namespace Render::GL

+ 82 - 0
render/geom/stone.cpp

@@ -0,0 +1,82 @@
+#include "stone.h"
+#include "../entity/registry.h"
+#include "../gl/mesh.h"
+#include "../gl/resources.h"
+#include "../scene_renderer.h"
+
+#include <QMatrix4x4>
+#include <QVector3D>
+#include <algorithm>
+#include <cmath>
+#include <numbers>
+#include <vector>
+
+namespace Render {
+namespace Geom {
+
+static auto create_stone_mesh() -> GL::Mesh * {
+  using GL::Vertex;
+  std::vector<GL::Vertex> verts;
+  std::vector<unsigned int> idx;
+
+  constexpr int k_latitude_segments = 8;
+  constexpr int k_longitude_segments = 10;
+  constexpr float k_base_radius = 0.15F;
+
+  for (int lat = 0; lat <= k_latitude_segments; ++lat) {
+    float const theta = static_cast<float>(lat) / k_latitude_segments *
+                        std::numbers::pi_v<float>;
+    float const sin_theta = std::sin(theta);
+    float const cos_theta = std::cos(theta);
+
+    for (int lon = 0; lon <= k_longitude_segments; ++lon) {
+      float const phi = static_cast<float>(lon) / k_longitude_segments * 2.0F *
+                        std::numbers::pi_v<float>;
+      float const sin_phi = std::sin(phi);
+      float const cos_phi = std::cos(phi);
+
+      float const noise = 1.0F + 0.15F * std::sin(phi * 3.0F + theta * 2.0F) +
+                          0.1F * std::cos(phi * 5.0F - theta * 3.0F);
+      float const radius = k_base_radius * noise;
+
+      float const x = radius * sin_theta * cos_phi;
+      float const y = radius * cos_theta;
+      float const z = radius * sin_theta * sin_phi;
+
+      QVector3D const normal =
+          QVector3D(sin_theta * cos_phi, cos_theta, sin_theta * sin_phi)
+              .normalized();
+
+      float const u = static_cast<float>(lon) / k_longitude_segments;
+      float const v = static_cast<float>(lat) / k_latitude_segments;
+
+      verts.push_back(
+          {{x, y, z}, {normal.x(), normal.y(), normal.z()}, {u, v}});
+    }
+  }
+
+  for (int lat = 0; lat < k_latitude_segments; ++lat) {
+    for (int lon = 0; lon < k_longitude_segments; ++lon) {
+      int const first = lat * (k_longitude_segments + 1) + lon;
+      int const second = first + k_longitude_segments + 1;
+
+      idx.push_back(first);
+      idx.push_back(second);
+      idx.push_back(first + 1);
+
+      idx.push_back(second);
+      idx.push_back(second + 1);
+      idx.push_back(first + 1);
+    }
+  }
+
+  return new GL::Mesh(verts, idx);
+}
+
+auto Stone::get() -> GL::Mesh * {
+  static GL::Mesh *mesh = create_stone_mesh();
+  return mesh;
+}
+
+} // namespace Geom
+} // namespace Render

+ 17 - 0
render/geom/stone.h

@@ -0,0 +1,17 @@
+#pragma once
+#include "../gl/mesh.h"
+#include <QMatrix4x4>
+#include <QVector3D>
+
+namespace Render {
+namespace Geom {
+
+class Stone {
+public:
+  static auto get() -> GL::Mesh *;
+};
+
+} // namespace Geom
+} // namespace Render
+
+#include "projectile_renderer.h"

+ 18 - 18
ui/qml/ProductionPanel.qml

@@ -379,9 +379,9 @@ Rectangle {
                                 anchors.fill: parent
                                 hoverEnabled: true
                                 onClicked: {
-                                    if (parent.isEnabled) {
+                                    if (parent.isEnabled)
                                         productionPanel.recruitUnit("archer");
-                                    }
+
                                 }
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 ToolTip.visible: containsMouse
@@ -485,9 +485,9 @@ Rectangle {
                                 anchors.fill: parent
                                 hoverEnabled: true
                                 onClicked: {
-                                    if (parent.isEnabled) {
+                                    if (parent.isEnabled)
                                         productionPanel.recruitUnit("swordsman");
-                                    }
+
                                 }
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 ToolTip.visible: containsMouse
@@ -591,9 +591,9 @@ Rectangle {
                                 anchors.fill: parent
                                 hoverEnabled: true
                                 onClicked: {
-                                    if (parent.isEnabled) {
+                                    if (parent.isEnabled)
                                         productionPanel.recruitUnit("spearman");
-                                    }
+
                                 }
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 ToolTip.visible: containsMouse
@@ -697,9 +697,9 @@ Rectangle {
                                 anchors.fill: parent
                                 hoverEnabled: true
                                 onClicked: {
-                                    if (parent.isEnabled) {
+                                    if (parent.isEnabled)
                                         productionPanel.recruitUnit("horse_swordsman");
-                                    }
+
                                 }
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 ToolTip.visible: containsMouse
@@ -803,9 +803,9 @@ Rectangle {
                                 anchors.fill: parent
                                 hoverEnabled: true
                                 onClicked: {
-                                    if (parent.isEnabled) {
+                                    if (parent.isEnabled)
                                         productionPanel.recruitUnit("horse_archer");
-                                    }
+
                                 }
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 ToolTip.visible: containsMouse
@@ -909,9 +909,9 @@ Rectangle {
                                 anchors.fill: parent
                                 hoverEnabled: true
                                 onClicked: {
-                                    if (parent.isEnabled) {
+                                    if (parent.isEnabled)
                                         productionPanel.recruitUnit("horse_spearman");
-                                    }
+
                                 }
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 ToolTip.visible: containsMouse
@@ -1015,9 +1015,9 @@ Rectangle {
                                 anchors.fill: parent
                                 hoverEnabled: true
                                 onClicked: {
-                                    if (parent.isEnabled) {
+                                    if (parent.isEnabled)
                                         productionPanel.recruitUnit("catapult");
-                                    }
+
                                 }
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 ToolTip.visible: containsMouse
@@ -1121,9 +1121,9 @@ Rectangle {
                                 anchors.fill: parent
                                 hoverEnabled: true
                                 onClicked: {
-                                    if (parent.isEnabled) {
+                                    if (parent.isEnabled)
                                         productionPanel.recruitUnit("ballista");
-                                    }
+
                                 }
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 ToolTip.visible: containsMouse
@@ -1227,9 +1227,9 @@ Rectangle {
                                 anchors.fill: parent
                                 hoverEnabled: true
                                 onClicked: {
-                                    if (parent.isEnabled) {
+                                    if (parent.isEnabled)
                                         productionPanel.recruitUnit("healer");
-                                    }
+
                                 }
                                 cursorShape: parent.isEnabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                 ToolTip.visible: containsMouse