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

Архитектура 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 можно почитать тут:

Комментарии (9)


  1. Brightori
    03.12.2018 16:28
    +2

    Хорошая статья ) спасиб ) на мой взгляд парадигма ECS очень подходит под разработку игр.


  1. DragonRider
    03.12.2018 19:19

    Как и во всем, каждый инструмент подходит для своей задачи. В последнем проекте я использую ECS только для игровой логики, для GUI он никак не подходит.


    1. Brightori
      04.12.2018 11:33

      ну всё логично, в GUI по моему мнению лучше всё на ивентах аля/или реактивный код. В особо запущенных случаях — MVC. А ECS это таки про компоненты и что сущности можно собирать из компонентов.


  1. Goldseeker
    03.12.2018 20:39
    +1

    Я уже и так и этак пытался применить ECS к сложной логике пошагового боя (например система d20) и никак не могу придумать случаи, когда ECS был быстр(никак не получается расположить данные, чтобы всё было кэш френдли) и удобен(создаются целые рои сущностей относящиеся к на самом деле к одному логическому объекту). Есть ли идеи как можно взглянуть на проблему так, чтобы ECS был полезен?
    Справедливости ради, d20 чудовищно сложная для имплементация система, что на ECS, что на ООП, так что это не в пику ECS.


  1. prambeat
    03.12.2018 21:17

    if (avatarRequest.Value.SpawnTime <= gs.Time)

    Меня немного это смущает. Что будет если тик не произойдет? И вообще, неужели необходимо спавнить игрока таким образом?


    1. Etlay Автор
      03.12.2018 23:33

      В нашей системе тик произойдет в любом случае. Спавн персонажей выполняется только на сервере, а сервер тики не пропускает. Данный пример был взят из реального проекта где используется отложенный по времени спавн персонажа. В целом все таймеры в игре реализованы таким же способом. Записывается время тика на котором должно произойти событие. И условие которое проверят текущее время системы.
      Тот же самый метод мы используем в примере HealthPowerUpMovementSystem:

      if(pair.Value.NextChangeDirection <= gs.Time)
      {
         pair.Value.NextChangeDirection = (uint) (healthPowerUpStats.SecondsToChangeDirection * GameState.Hz);
       movement.Velocity *= -1;
      }


  1. CorvOrk
    04.12.2018 13:25

    Здравствуйте Etlay,
    Большое спасибо за статью, есть несколько вопросов после прочтения.
    1) Правильно ли я понимаю, что у Вас возможно хранение компонентов без привязки к Entity?
    (например компонент SpawnAvatarRequest, который не привязан к Entity и несет в себе данные о том, кого и когда необходимо заспаунить)
    Почему не сделать SpawnAvatarRequst сущностью? Мое понимание ECS основано на том, что компонент обязательно должен принадлежать сущности.
    2) Компоненты могут добавляться динамически в «рантайме». Действительно ли это нужно и используется ли это в игре?
    То есть, можно в RuleBook расписать для каждой сущности набор ее компонент при спауне. В процессе игры добавляются ли к сущности новые компоненты, кроме тех, что добавились на спауне?
    (интересен конкретно Ваш опыт, в теории конечно понятно, что динамическое добавление компонента к сущности нужно, но встречается ли это на практике у Вас? особенно в шутере)
    3) Так как мир инициализируется на сервере и сущности спаунятся на сервере — бывают ли ситуации,
    когда на клиенте эта самая инициализация (в момент передачи полного gameState при первом запросе или при рестарте соединения)
    превышает время, отведенное для тика? (например Hz 20=50 ms, а полный gameState при ините мира/рестарте сети занимает 120ms).
    Если такие ситуации бывают, как Вы с ними работаете?
    4) var playerEntity = gs.WorldState.CreateEntity(avatarRequest.Value.PlayerId); И далее идет работа по инициализации компонент (playerEntity.Add...()).
    В интерфейсе у сущности для каждого компонента есть своя функция по добавлению? Можете этот момент прокомментировать?) Правильно ли я понимаю, что мне как программисту при добавлении компонента нужно в интерфейсе сущности также еще добавить функцию, которая и реализует добавление этого нового компонента?

    Еще раз спасибо за статью.


    1. Etlay Автор
      04.12.2018 13:56

      1) Нет, все компоненты привязаны к какой-то entity. Я упустил в статье момент создания SpawnAvatarRequest. Он у нас происходит не внутри систем ECS, а при первоночальном создании GameState:

      var avatarRequestEntity = gs.WorldState.CreateEntity();
      avatarRequestEntity.AddSpawnAvatarRequest(1); // создаем игроков на первом тике мира
      

      2) Да такой механизм нужен и используется. Это по-сути одна из «фичей» ECS. Когда можно динамически добавить новое поведение к уже существующей сущности. На примере нашего шутера игроку в рантайме могут добавляться компоненты: неуязвимости, невидимости, ускорения движения и т.д. Пример кода из системы расчета невидимости:
      if (ghost.IsInHidingZone) // если игрок в зоне "невидимости"
      {  
           playerEntity.DelAimable(); // удаляем компонент, который отвечает за то что в игрока можно целится
           playerEntity.AddInvisible(); // добавляем компонент невидимости
      }
      

      3) Да такие ситуации бывают, вообще ответ на этот вопрос достоин отдельной статьи. О том как работает синхронизация я рассказывал в статье о сетевом коде проекта
      Если вкратце, то клиент просто пропускает симуляцию нескольких тиков в случае хиккапа, и берет последние валидные данные с сервера. (игрок при этом видит подвисание).

      4) Верно у нас в интерфейсе entity для каждого компонента существуют методы Add*MyComponent*() и Del*MyComponent(). Однако мы используем кодогенерацию в нашей ECS, и вам как программисту нужно лишь написать свой компонент, и указать что он используется в Enitity. Вот пример как выглядит класс Entity в проекте кодогенерации:
      public class Entity
      {
             public Transform Transform;
             public Movement Movement;
             public CharacterRotation CharacterRotation;
      
             [DontPack]
             public AmmoToAdd AmmoToAdd;
             
             [DontPack]
             public ShotDamage ShotDamage;
      }
      

      Кодогенератор добавить в проект все необходимые методы по созданию, удалению, итерированию и сериализации этого компонента.


      1. CorvOrk
        04.12.2018 17:38

        Спасибо за ответ. Это довольно интересная тема для меня, если не против, еще пару вопросов:
        1. Хотел уточнить (ответ впринципи ясен, но в статье этого не нашел), правильно ли я понимаю, что все поля, которые находятся внутри компонента должны быть либо простыми типами, либо IComponent,
        чтобы кодогенерация могла понять, как их сериализовать и т.п. (аналог UPROPERTY в Unreal)? Не видно, чтобы сами поля обрамлялись в атрибуты (например [IComponent] Invulnerability{[UProperty] int duration;}),
        значит поля рефлексией итерируются и делается определенное заключение. То есть в компоненте Transform поле Position является [IComponent]public class Vector{...}?
        2. Если говорить о взаимодействии Моделей и Презентера, можете общими словами описать, как это происходит? Как я предполагаю, у Вас в ПрезенторКонтекстаБоя создается и инициализируется ECS.
        В этом презенторе происходит подписывание на событие добавления/удаление сущностей. Когда сущность спаунится, презентор оповещается об этом и создает под нее ПрезентерСущности.
        Далее вопрос, как построить презентер для компонентов сущности, которые добавляются в рантайме. Например сущность может стать на некоторое время неуязвимой и придумали визуально показывать щит вокруг персонажа.
        Как ПрезентерСущности узнает, что появился новый компонент и нужно внутри добавить ПрезентерНеуязвимости? Он оповещается о всех новых добавленных компонентах и под каждый компонент
        подбирает и создает нужный презентер, где происходит примерно следующее? public class InvulPresenter(IEntity entity, IInvulView viewContainer) {var invul = entity.GetInvul(); invul.Changed += OnInvulChanged; invul.Removed += OnInvulRemoved; ...}