Browse Source

Add healer unit with healing system and nation-specific configurations

Co-authored-by: djeada <[email protected]>
copilot-swe-agent[bot] 1 month ago
parent
commit
0b3b161e3d

+ 2 - 0
app/core/game_engine.cpp

@@ -68,6 +68,7 @@
 #include "game/systems/formation_planner.h"
 #include "game/systems/game_state_serializer.h"
 #include "game/systems/global_stats_registry.h"
+#include "game/systems/healing_system.h"
 #include "game/systems/movement_system.h"
 #include "game/systems/nation_id.h"
 #include "game/systems/nation_registry.h"
@@ -145,6 +146,7 @@ GameEngine::GameEngine(QObject *parent)
   m_world->addSystem(std::make_unique<Game::Systems::MovementSystem>());
   m_world->addSystem(std::make_unique<Game::Systems::PatrolSystem>());
   m_world->addSystem(std::make_unique<Game::Systems::CombatSystem>());
+  m_world->addSystem(std::make_unique<Game::Systems::HealingSystem>());
   m_world->addSystem(std::make_unique<Game::Systems::CaptureSystem>());
   m_world->addSystem(std::make_unique<Game::Systems::AISystem>());
   m_world->addSystem(std::make_unique<Game::Systems::ProductionSystem>());

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

@@ -143,6 +143,41 @@
         "individuals_per_unit": 9,
         "max_units_per_row": 3
       }
+    },
+    {
+      "id": "healer",
+      "display_name": "Sacred Band Healer",
+      "production": {
+        "cost": 70,
+        "build_time": 6.8,
+        "priority": 7,
+        "is_melee": false
+      },
+      "combat": {
+        "health": 95,
+        "max_health": 95,
+        "speed": 2.7,
+        "vision_range": 14.0,
+        "ranged_range": 7.5,
+        "ranged_damage": 4,
+        "ranged_cooldown": 2.2,
+        "melee_range": 1.5,
+        "melee_damage": 3,
+        "melee_cooldown": 1.5,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.54,
+        "selection_ring_size": 1.2,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/carthage/healer"
+      },
+      "formation": {
+        "individuals_per_unit": 12,
+        "max_units_per_row": 4
+      }
     }
   ]
 }

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

@@ -143,6 +143,41 @@
         "individuals_per_unit": 9,
         "max_units_per_row": 3
       }
+    },
+    {
+      "id": "healer",
+      "display_name": "Healer",
+      "production": {
+        "cost": 75,
+        "build_time": 7.0,
+        "priority": 8,
+        "is_melee": false
+      },
+      "combat": {
+        "health": 100,
+        "max_health": 100,
+        "speed": 2.5,
+        "vision_range": 14.0,
+        "ranged_range": 8.0,
+        "ranged_damage": 5,
+        "ranged_cooldown": 2.0,
+        "melee_range": 1.5,
+        "melee_damage": 3,
+        "melee_cooldown": 1.5,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.55,
+        "selection_ring_size": 1.2,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/kingdom/healer"
+      },
+      "formation": {
+        "individuals_per_unit": 12,
+        "max_units_per_row": 4
+      }
     }
   ]
 }

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

@@ -143,6 +143,41 @@
         "individuals_per_unit": 9,
         "max_units_per_row": 3
       }
+    },
+    {
+      "id": "healer",
+      "display_name": "Medicus",
+      "production": {
+        "cost": 80,
+        "build_time": 7.2,
+        "priority": 8,
+        "is_melee": false
+      },
+      "combat": {
+        "health": 105,
+        "max_health": 105,
+        "speed": 2.6,
+        "vision_range": 14.5,
+        "ranged_range": 8.5,
+        "ranged_damage": 6,
+        "ranged_cooldown": 1.8,
+        "melee_range": 1.5,
+        "melee_damage": 3,
+        "melee_cooldown": 1.5,
+        "can_ranged": false,
+        "can_melee": true
+      },
+      "visuals": {
+        "render_scale": 0.56,
+        "selection_ring_size": 1.25,
+        "selection_ring_y_offset": 0.0,
+        "selection_ring_ground_offset": 0.0,
+        "renderer_id": "troops/roman/healer"
+      },
+      "formation": {
+        "individuals_per_unit": 12,
+        "max_units_per_row": 4
+      }
     }
   ]
 }

+ 2 - 0
game/CMakeLists.txt

@@ -42,6 +42,7 @@ add_library(game_systems STATIC
     systems/production_system.cpp
     systems/capture_system.cpp
     systems/terrain_alignment_system.cpp
+    systems/healing_system.cpp
     systems/owner_registry.cpp
     systems/troop_count_registry.cpp
     systems/global_stats_registry.cpp
@@ -71,6 +72,7 @@ add_library(game_systems STATIC
     units/horse_archer.cpp
     units/horse_spearman.cpp
     units/spearman.cpp
+    units/healer.cpp
     units/troop_catalog.cpp
     units/troop_catalog_loader.cpp
     units/factory.cpp

+ 10 - 0
game/core/component.h

@@ -230,4 +230,14 @@ public:
   float standUpDuration;
 };
 
+class HealerComponent : public Component {
+public:
+  HealerComponent() = default;
+
+  float healing_range{8.0F};
+  int healing_amount{5};
+  float healing_cooldown{2.0F};
+  float timeSinceLastHeal{0.0F};
+};
+
 } // namespace Engine::Core

+ 81 - 0
game/systems/healing_system.cpp

@@ -0,0 +1,81 @@
+#include "healing_system.h"
+#include "../core/component.h"
+#include "../core/world.h"
+#include <cmath>
+#include <vector>
+
+namespace Game::Systems {
+
+void HealingSystem::update(Engine::Core::World *world, float deltaTime) {
+  processHealing(world, deltaTime);
+}
+
+void HealingSystem::processHealing(Engine::Core::World *world,
+                                   float deltaTime) {
+  auto healers = world->getEntitiesWith<Engine::Core::HealerComponent>();
+
+  for (auto *healer : healers) {
+    if (healer->hasComponent<Engine::Core::PendingRemovalComponent>()) {
+      continue;
+    }
+
+    auto *healer_unit = healer->getComponent<Engine::Core::UnitComponent>();
+    auto *healer_transform =
+        healer->getComponent<Engine::Core::TransformComponent>();
+    auto *healer_comp = healer->getComponent<Engine::Core::HealerComponent>();
+
+    if ((healer_unit == nullptr) || (healer_transform == nullptr) ||
+        (healer_comp == nullptr)) {
+      continue;
+    }
+
+    if (healer_unit->health <= 0) {
+      continue;
+    }
+
+    healer_comp->timeSinceLastHeal += deltaTime;
+
+    if (healer_comp->timeSinceLastHeal < healer_comp->healing_cooldown) {
+      continue;
+    }
+
+    auto units = world->getEntitiesWith<Engine::Core::UnitComponent>();
+    for (auto *target : units) {
+      if (target->hasComponent<Engine::Core::PendingRemovalComponent>()) {
+        continue;
+      }
+
+      auto *target_unit = target->getComponent<Engine::Core::UnitComponent>();
+      auto *target_transform =
+          target->getComponent<Engine::Core::TransformComponent>();
+
+      if ((target_unit == nullptr) || (target_transform == nullptr)) {
+        continue;
+      }
+
+      if (target_unit->health <= 0 ||
+          target_unit->health >= target_unit->max_health) {
+        continue;
+      }
+
+      if (target_unit->owner_id != healer_unit->owner_id) {
+        continue;
+      }
+
+      float const dx = target_transform->position.x - healer_transform->position.x;
+      float const dz = target_transform->position.z - healer_transform->position.z;
+      float const dist = std::sqrt(dx * dx + dz * dz);
+
+      if (dist <= healer_comp->healing_range) {
+        target_unit->health += healer_comp->healing_amount;
+        if (target_unit->health > target_unit->max_health) {
+          target_unit->health = target_unit->max_health;
+        }
+      }
+    }
+
+    healer_comp->timeSinceLastHeal = 0.0F;
+  }
+}
+
+} // namespace Game::Systems

+ 19 - 0
game/systems/healing_system.h

@@ -0,0 +1,19 @@
+#pragma once
+
+#include "../core/system.h"
+
+namespace Engine::Core {
+class World;
+}
+
+namespace Game::Systems {
+
+class HealingSystem : public Engine::Core::System {
+public:
+  void update(Engine::Core::World *world, float deltaTime) override;
+
+private:
+  void processHealing(Engine::Core::World *world, float deltaTime);
+};
+
+} // namespace Game::Systems

+ 6 - 0
game/units/factory.cpp

@@ -1,6 +1,7 @@
 #include "factory.h"
 #include "archer.h"
 #include "barracks.h"
+#include "healer.h"
 #include "horse_archer.h"
 #include "horse_spearman.h"
 #include "horse_swordsman.h"
@@ -42,6 +43,11 @@ void registerBuiltInUnits(UnitFactoryRegistry &reg) {
     return HorseSpearman::Create(world, params);
   });
 
+  reg.registerFactory(SpawnType::Healer, [](Engine::Core::World &world,
+                                            const SpawnParams &params) {
+    return Healer::Create(world, params);
+  });
+
   reg.registerFactory(SpawnType::Barracks, [](Engine::Core::World &world,
                                               const SpawnParams &params) {
     return Barracks::Create(world, params);

+ 95 - 0
game/units/healer.cpp

@@ -0,0 +1,95 @@
+#include "healer.h"
+#include "../core/component.h"
+#include "../core/event_manager.h"
+#include "../core/world.h"
+#include "../systems/troop_profile_service.h"
+#include "units/troop_type.h"
+#include "units/unit.h"
+#include <memory>
+#include <qvectornd.h>
+
+static inline auto team_color(int owner_id) -> QVector3D {
+  switch (owner_id) {
+  case 1:
+    return {0.20F, 0.55F, 1.00F};
+  case 2:
+    return {1.00F, 0.30F, 0.30F};
+  case 3:
+    return {0.20F, 0.80F, 0.40F};
+  case 4:
+    return {1.00F, 0.80F, 0.20F};
+  default:
+    return {0.8F, 0.9F, 1.0F};
+  }
+}
+
+namespace Game::Units {
+
+Healer::Healer(Engine::Core::World &world) : Unit(world, TroopType::Healer) {}
+
+auto Healer::Create(Engine::Core::World &world,
+                    const SpawnParams &params) -> std::unique_ptr<Healer> {
+  auto unit = std::unique_ptr<Healer>(new Healer(world));
+  unit->init(params);
+  return unit;
+}
+
+void Healer::init(const SpawnParams &params) {
+
+  auto *e = m_world->createEntity();
+  m_id = e->getId();
+
+  const auto nation_id = resolve_nation_id(params);
+  auto profile = Game::Systems::TroopProfileService::instance().get_profile(
+      nation_id, TroopType::Healer);
+
+  m_t = e->addComponent<Engine::Core::TransformComponent>();
+  m_t->position = {params.position.x(), params.position.y(),
+                   params.position.z()};
+  float const scale = profile.visuals.render_scale;
+  m_t->scale = {scale, scale, scale};
+
+  m_r = e->addComponent<Engine::Core::RenderableComponent>("", "");
+  m_r->visible = true;
+  m_r->rendererId = profile.visuals.renderer_id;
+
+  m_u = e->addComponent<Engine::Core::UnitComponent>();
+  m_u->spawn_type = params.spawn_type;
+  m_u->health = profile.combat.health;
+  m_u->max_health = profile.combat.max_health;
+  m_u->speed = profile.combat.speed;
+  m_u->owner_id = params.player_id;
+  m_u->vision_range = profile.combat.vision_range;
+  m_u->nation_id = nation_id;
+
+  if (params.aiControlled) {
+    e->addComponent<Engine::Core::AIControlledComponent>();
+  }
+
+  QVector3D const tc = team_color(m_u->owner_id);
+  m_r->color[0] = tc.x();
+  m_r->color[1] = tc.y();
+  m_r->color[2] = tc.z();
+
+  m_mv = e->addComponent<Engine::Core::MovementComponent>();
+  if (m_mv != nullptr) {
+    m_mv->goalX = params.position.x();
+    m_mv->goalY = params.position.z();
+    m_mv->target_x = params.position.x();
+    m_mv->target_y = params.position.z();
+  }
+
+  auto *healer_comp = e->addComponent<Engine::Core::HealerComponent>();
+  if (healer_comp != nullptr) {
+    healer_comp->healing_range = profile.combat.vision_range * 0.6F;
+    healer_comp->healing_amount = profile.combat.ranged_damage > 0 
+                                    ? profile.combat.ranged_damage 
+                                    : 5;
+    healer_comp->healing_cooldown = profile.combat.ranged_cooldown;
+  }
+
+  Engine::Core::EventManager::instance().publish(
+      Engine::Core::UnitSpawnedEvent(m_id, m_u->owner_id, m_u->spawn_type));
+}
+
+} // namespace Game::Units

+ 17 - 0
game/units/healer.h

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

+ 14 - 0
game/units/spawn_type.h

@@ -16,6 +16,7 @@ enum class SpawnType : std::uint8_t {
   MountedKnight,
   HorseArcher,
   HorseSpearman,
+  Healer,
   Barracks
 };
 
@@ -33,6 +34,8 @@ inline auto spawn_typeToQString(SpawnType type) -> QString {
     return QStringLiteral("horse_archer");
   case SpawnType::HorseSpearman:
     return QStringLiteral("horse_spearman");
+  case SpawnType::Healer:
+    return QStringLiteral("healer");
   case SpawnType::Barracks:
     return QStringLiteral("barracks");
   }
@@ -70,6 +73,10 @@ inline auto tryParseSpawnType(const QString &value, SpawnType &out) -> bool {
     out = SpawnType::HorseSpearman;
     return true;
   }
+  if (lowered == QStringLiteral("healer")) {
+    out = SpawnType::Healer;
+    return true;
+  }
   if (lowered == QStringLiteral("barracks")) {
     out = SpawnType::Barracks;
     return true;
@@ -97,6 +104,9 @@ spawn_typeFromString(const std::string &str) -> std::optional<SpawnType> {
   if (str == "horse_spearman") {
     return SpawnType::HorseSpearman;
   }
+  if (str == "healer") {
+    return SpawnType::Healer;
+  }
   if (str == "barracks") {
     return SpawnType::Barracks;
   }
@@ -125,6 +135,8 @@ inline auto spawn_typeToTroopType(SpawnType type) -> std::optional<TroopType> {
     return TroopType::HorseArcher;
   case SpawnType::HorseSpearman:
     return TroopType::HorseSpearman;
+  case SpawnType::Healer:
+    return TroopType::Healer;
   case SpawnType::Barracks:
     return std::nullopt;
   }
@@ -145,6 +157,8 @@ inline auto spawn_typeFromTroopType(TroopType type) -> SpawnType {
     return SpawnType::HorseArcher;
   case TroopType::HorseSpearman:
     return SpawnType::HorseSpearman;
+  case TroopType::Healer:
+    return SpawnType::Healer;
   }
   return SpawnType::Archer;
 }

+ 8 - 1
game/units/troop_type.h

@@ -15,7 +15,8 @@ enum class TroopType {
   Spearman,
   MountedKnight,
   HorseArcher,
-  HorseSpearman
+  HorseSpearman,
+  Healer
 };
 
 inline auto troop_typeToQString(TroopType type) -> QString {
@@ -32,6 +33,8 @@ inline auto troop_typeToQString(TroopType type) -> QString {
     return QStringLiteral("horse_archer");
   case TroopType::HorseSpearman:
     return QStringLiteral("horse_spearman");
+  case TroopType::Healer:
+    return QStringLiteral("healer");
   }
   return QStringLiteral("archer");
 }
@@ -70,6 +73,10 @@ inline auto tryParseTroopType(const QString &value, TroopType &out) -> bool {
     out = TroopType::HorseSpearman;
     return true;
   }
+  if (lowered == QStringLiteral("healer")) {
+    out = TroopType::Healer;
+    return true;
+  }
   return false;
 }