Игровые фичи с помощью ECS: добавляем в шутер аптечки

в 9:02, , рубрики: ecs, Gamedev, games, pvp, shooter, unity3d, Блог компании Pixonic, Проектирование и рефакторинг, разработка, разработка игр, разработка мобильных приложений

Игровые фичи с помощью ECS: добавляем в шутер аптечки - 1

От ковров перейдем к серьезным вещам. Мы уже рассказали про ECS, какие есть фреймворки для Unity и почему написали свой (со списком можно ознакомиться в конце статьи). А сейчас остановимся на конкретных примерах, как используем ECS в нашем новом мобильном PvP-шутере и как реализуем игровые фичи. Отмечу, что применяем эту архитектуру мы только для симуляции мира на сервере и системы предсказания на клиенте. Визуализация и рендер объектов реализованы с помощью MPV-паттерна — но сегодня не об этом.

Архитектура ECS является Data-oriented, все данные игрового мира хранятся в так называемом GameState и представляют собой список сущностей (entities) с некоторыми компонентами (components) на каждой из них. Набор компонентов определяет поведение объекта. А логика поведения компонентов сосредоточена в системах.

Геймстейт в нашей ECS состоит из двух частей: RuleBook и WorldState. RuleBook — это набор компонентов, которые не меняются в течение матча. Там хранятся все статические данные (характеристики оружия/персонажей, составы команд) и отправляются на клиент всего один раз — при авторизации на гейм-сервере.

Рассмотрим простой пример: спавн персонажа и его перемещение в 2D-пространстве с помощью двух джойстиков. Для начала объявим компоненты.

Этот определяет игрока и необходим для визуализации персонажа:

[Component]
public class Player
{
}

Следующий компонент — «сигнал» на создание нового персонажа. Он содержит два поля: время спавна персонажа (в тиках) и его ID:

[Component]
public class PlayerSpawnRequest
{
 public int SpawnTime;
 public unit PlayerId;
}

Компонент ориентации объекта в пространстве:

[Component]
public class Transform
{
    public Vector2 Position;
    public float Rotation;
}

Компонент, хранящий текущую скорость объекта:

[Component]
public class Movement
{
    public Vector2 Velocity;
    public float RotateToAngle;
}

Компонент, хранящий инпут игрока (вектор джойстика движения и вектор джойстика вращения персонажа):

[Component]
public class Input
{
    public Vector2 MoveVector;
    public Vector2 RotateVector;
}

Компонент со статическими характеристиками персонажа (он будет храниться в RuleBook, так как это базовая характеристика и не изменяется в течение игровой сессии):

[Component]
public class PlayerStats
{
    public float MoveSpeed;
}

При декомпозиции фичи на системы мы часто руководствуемся принципом единственной ответственности (single responsibility principle): каждая система должна выполнять одну и только одну функцию.

Фичи могут состоять из нескольких систем. Начнем с определения системы спавна персонажа. Система проходит по всем запросам на создание персонажа в геймстейте и если текущее время мира совпадает с требуемым — создает новую сущность и прикрепляет к ней компоненты, определяющие игрока: Player, Transform, Movement.

public class SpawnPlayerSystem : ExecutableSystem
   {
       public override void Execute(GameState gs)
       {
           var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.SpawnAvatarRequest);
           foreach (var avatarRequest in gs.WorldState.SpawnAvatarRequest)
           {
               if (avatarRequest.Value.SpawnTime == gs.Time)
               {
                  
                   // create new entity with player ID
                   var playerEntity = gs.WorldState.CreateEntity(avatarRequest.Value.PlayerId);
                   
                   // add components to determinate player behaviour
                   playerEntity.AddPlayer();
                   playerEntity.AddTransform(Vector2.zero, 0);
                   playerEntity.AddMovement(Vector2.zero, 0);
                   
                   // delete player spawn request
                   deleter.Delete(avatarRequest.Key);
               }
           }

       }
   }

Теперь рассмотрим движение игрока по джойстику. Нам понадобится система, которая будет обрабатывать инпут. Она проходит по всем компонентам инпута, рассчитывает скорость игрока (стоит он или двигается) и преобразует вектор джойстика поворота в угол вращения:

MovementControlSystem

public class MovementControlSystem : ExecutableSystem
    {
        public override void Execute(GameState gs)
        {
            var playerStats = gs.RuleBook.PlayerStats[1];
            foreach (var pair in gs.Input)
            {
                var movement = gs.WorldState.Movement[pair.Key];
                movement.Velocity = pair.Value.MoveVector.normalized * playerStats.MoveSpeed;
                movement.RotateToAngle =  Math.Atan2(pair.Value.RotateVector.y, pair.Value.RotateVector.x);
            }
        }
    }

Следующая — система движения:

public class MovementSystem : ExecutableSystem
    {
        public override void Execute(GameState gs)
        {
            foreach (var pair in gs.WorldState.Movement)
            {
                var transform = gs.WorldState.Transform[pair.Key];
                transform.Position += pair.Value.Velocity * GameState.TickDurationSec;
            }
        }
    }

Система, отвечающая за поворот объекта:

public class RotationSystem : ExecutableSystem
    {
        public override void Execute(GameState gs)
        {
            foreach (var pair in gs.WorldState.Movement)
            {
                var transform = gs.WorldState.Transform[pair.Key];
                transform.Angle = pair.Value.RotateToAngle;
            }
        }
    }

Системы MovementSystem и RotationSystem работают только с компонентами Transform и Movement. Они независимы от сущности игрока. Если в нашей игре появятся другие сущности с компонентами Movement и Transform, то логика перемещения также будет работать с ними.

Для примера добавим аптечку, которая будет двигаться по прямой вдоль спавна и при подборе восполнять здоровье персонажа. Объявим компоненты:

[Component]
public class Health
{
    public uint CurrentHealth;
    public uint MaxHealth;
}

[Component]
public class HealthPowerUp
{
     public uint NextChangeDirection;
}

[Component]
public class HealthPowerUpSpawnRequest
{
    public uint SpawnRequest;
}

[Component]
public class HealthPowerUpStats
{
     public float HealthRestorePercent;
     public float MoveSpeed;
     public float SecondsToChangeDirection;
     public float PickupRadius;
     public float TimeToSpawn;
}

Модифицируем компонент статов персонажа, добавив туда максимальное количество жизней:

[Component]
public class PlayerStats
{
    public float MoveSpeed;
    public uint MaxHealth;
}

Теперь модифицируем систему спавна персонажа, чтобы персонаж появлялся с максимальным здоровьем:

public class SpawnPlayerSystem : ExecutableSystem
   {
       public override void Execute(GameState gs)
       {
           var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.SpawnAvatarRequest);
           var playerStats = gs.RuleBook.PlayerStats[1];
           foreach (var avatarRequest in gs.WorldState.SpawnAvatarRequest)
           {
               if (avatarRequest.Value.SpawnTime <= gs.Time)
               {
                  
                   // create new entity with player ID
                   var playerEntity = gs.WorldState.CreateEntity(avatarRequest.Value.PlayerId);
                   
                   // add components to determinate player behaviour
                   playerEntity.AddPlayer();
                   playerEntity.AddTransform(Vector2.zero, 0);
                   playerEntity.AddMovement(Vector2.zero, 0);
                   playerEntity.AddHealth(playerStats.MaxHealth, playerStats.MaxHealth);
                   
                   // delete player spawn request
                   deleter.Delete(avatarRequest.Key);
               }
           }

       }
   }

Затем объявляем систему спавна наших аптечек:

public class SpawnHealthPowerUpSystem : ExecutableSystem
   {
       public override void Execute(GameState gs)
       {
           var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.HealthPowerUpSpawnRequest);
           var healthPowerUpStats = gs.RoolBook.healthPowerUpStats[1];
           foreach (var spawnRequest in gs.WorldState.HealthPowerUpSpawnRequest)
           {
                   // create new entity 
                   var powerUpEntity = gs.WorldState.CreateEntity();
                   
                   // add components to determine healthPowerUp behaviour
                   powerUpEntity.AddHealthPowerUp((uint)(healthPowerUpStats.SecondsToChangeDirection * GameState.Hz));
                   playerEntity.AddTransform(Vector2.zero, 0);
                   playerEntity.AddMovement(healthPowerUpStats.MoveSpeed, 0);
                   
                   // delete player spawn request
                   deleter.Delete(spawnRequest.Key);
           }
       }
   }

И систему изменения скорости движения аптечки. Для упрощения, аптечка будет менять направление движения каждые несколько секунд:

public class HealthPowerUpMovementSystem : ExecutableSystem
{
    public override void Execute(GameState gs)
    {
          var healthPowerUpStats = gs.RoolBook.healthPowerUpStats[1];
          foreach (var pair in gs.WorldState.HealthPowerUp)
          {
              var movement = gs.WorldState.Movement[pair.Key];
              if(pair.Value.NextChangeDirection <= gs.Time)
              {
                 pair.Value.NextChangeDirection = (uint) (healthPowerUpStats.SecondsToChangeDirection * GameState.Hz);
                 movement.Velocity *= -1;
              }
          }
    }
}

Так как мы уже объявили MovementSystem для перемещения объектов в игре, нам понадобится только система HealthPowerUpMovementSystem для изменения вектора скорости движения, каждые N секунд.

Теперь допиливаем подбор аптечки и начисление HP персонажу. Нам понадобится еще один вспомогательный компонент для хранения количества жизней, которые получит персонаж после подбора аптечки.

[Component]
public class HealthToAdd
{
public int Health;
public Entity Target;
}

И компонент для удаления нашего поверапа:

[Component]
public class DeleteHealthPowerUpRequest
{
}

Пишем систему, обрабатывающую подбор аптечки:

public class HealthPowerUpPickUpSystem : ExecutableSystem
{
  public override void Execute(GameState gs)
  {
      var healthPowerUpStats = gs.RoolBook.healthPowerUpStats[1];
      
      foreach(var powerUpPair in gs.WorldState.HealthPowerUp)
      {
         var powerUpTransform = gs.WorldState.Transform[powerUpPair.Key];
         foreach(var playerPair in gs.WorldState.Player)
         {
            var playerTransform = gs.WorldState.Transform[playerPair.Key];
            var distance = Vector2.Distance(powerUpTransform.Position, playerTransform.Position)
           if(distance < healthPowerUpStats.PickupRadius)
           {

              var healthToAdd = gs.WorldState.Health[playerPair.Key].MaxHealth * healthPowerUpStats.HealthRestorePercent;
           
              var entity = gs.WorldState.CreateEntity();
              entity.AddHealthToAdd(healthToAdd, gs.WorldState.Player[playerPair.Key]);

              var powerUpEnity = gs.WorldState[powerUpPair.Key];
              powerUpEnity.AddDeleteHealthPowerUpRequest();
              break;
           }
         }
      }
  }
}

Система проходит по всем активным поверапам и рассчитывает расстояние до игрока. Если какой-либо игрок находится в радиусе подбора, система создает два компонента-запроса:

HealthToAdd — «запрос» на добавление жизней персонажу;
DeleteHealthPowerUpRequest — «запрос» на удаление аптечки.

Почему не добавить нужное количество жизней в этой же системе? Мы исходим из того, что игрок получает HP не только от аптечек, но и из других источников. В этом случае целесообразнее разделить системы подбора аптечки и систему начисления жизней персонажа. К тому же это больше соответствует Single Responsibility Principle.

Реализуем систему начисления жизней персонажу:

public class HealingSystem : ExecutableSystem
{
  public override void Execute(GameState gs)
  {
      var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.HealthToAdd);
      foreach(var healtToAddPair in gs.WorldState.HealthToAdd)
      {
         var healthToAdd = healtToAddPair.Value.Health;
         var health = healtToAddPair.Value.Target.Health;

         health.CurrentHealth += healthToAdd;
         health.CurrentHealth = Mathf.Clamp(health.CurrentHealth, 0, health.MaxHealth);
         deleter.Delete(healtToAddPair.Key);
      }
  }
}

Система проходится по всем компонентам HealthToAdd, начисляет нужное количество жизней в компонент Health у целевой сущности Target. Данная сущность ничего не знает о источнике и целевом объекте и довольно универсальная. Эту систему можно использовать не только для начисления жизней персонажу, но для любых объектов, которые предполагают наличие жизней и их регенерацию.

Для реализации фичи с аптечками осталось добавить последнюю систему: систему удаления аптечки после ее подбора.

public class DeleteHealthPowerUpSystem : ExecutableSystem
{
  public override void Execute(GameState gs)
  {
      var deleter = gs.Pools.Deferred.GetDeleter(gs.WorldState.DeleteHealthPowerUpReques);
      foreach(var healthRequest in gs.WorldState.DeleteHealthPowerUpReques)
      {
         var id = healthRequest.Key;
         gs.WorldState.DelHealthPowerUp(id);
         gs.WorldState.DelTransform(id);
         gs.WorldState.DelMovement(id);
         deleter.Delete(id);
      }
  }
}

В системе HealthPowerUpPickUpSystem создается запрос на удаление аптечки. Система DeleteHealthPowerUpSystem проходит по всем таким запросам и удаляет все компоненты, принадлежащие сущности аптечки.

Готово. Все системы из наших примеров реализованы. Есть один момент работы с ECS — все системы выполняются последовательно и этот порядок важен.

В нашем примере порядок систем следующий:

_systems = new List<ExecutableSystem>
{
new SpawnPlayerSystem(),
new SpawnHealthPowerUpSystem(),

new MovementControlSystem(),
new HealthPowerUpMovementSystem(),

new MovementSystem(),
new RotationSystem(),

new HealthPowerUpPickUpSystem(),
new HealingSystem(),
new DeleteHealthPowerUpSystem()
};

В общем случае первыми идут системы, отвечающие за создание новых сущностей и компонентов. Затем системы обработки и в конце — системы удаления и очистки.

При должной декомпозиции ECS обладает большой гибкостью. Да, наша реализация не идеальна, но позволяет имплементировать фичи в сжатые сроки, а также обладает хорошей производительностью на современных мобильных устройствах. Еще об ECS можно почитать тут:

Автор: Алексей Дюдя

Источник

* - обязательные к заполнению поля