Друзья, это первая статья по фреймворку LeoECS из предстоящей серии, которая позволит вам быстрее понять, как работать с LeoECS в Unity и решить некоторые виды проблем, возникающих на практике. Все советы, изложенные в них, не представляют собою какой-то свод правил, способы построения подходов, а скорее набор рекомендаций и best practices, которые помогут вам освоиться в работе с фреймворком. Перед чтением убедитесь, что вы понимаете принцип работы архитектурного паттерна Entity Component System (ECS), и ознакомьтесь с документацией LeoECS, так как в процессе изучения фреймворка мы создадим простую игру жанра Top-Down shooter, рассмотрим часто возникающие проблемы и способы решения, и отвлекаться на различные вопросы, связанные с концепцией ECS, не будем.
Следующая часть

LeoECS – это одна из самых быстрых, легковесных и простых реализаций паттерна Entity Component System на языке C# с опциональной интеграцией с Unity: визуальный дебаггер, эмиттеры событий физики и uGUI, конвертер сцены с GameObject’ами и MonoBehaviour’ами в сущности и компоненты в “мире” ECS. Фреймворк стабильно работает уже долгое время, на нем выпущен далеко не один коммерческий проект.

Давайте перейдем к практике и начнем с самого главного класса в вашем проекте – EcsStartup. Создайте простой MonoBehaviour класс и прикрепите его к какому-нибудь объекту в сцене – лучше выделить под это отдельный пустой GameObject. Вы можете использовать опциональный интегратор с Unity, тогда EcsStartup и необходимые папки (Components, Systems, Services, UnityComponents) вы можете сгенерировать автоматически.

public class EcsStartup : MonoBehaviour
{
    private EcsWorld ecsWorld;
    private EcsSystems systems;
 
    private void Start()
    {
        ecsWorld = new EcsWorld(); // создаем новый EcsWorld
        systems = new EcsSystems(ecsWorld); // и группу систем в этом мире
    	
        systems
            .Add(new PlayerInitSystem()) // добавляем первую систему
            .Init(); // обязательно инициализируем группу систем
    }
 
    private void Update()
    {
        systems?.Run(); // запускаем системы каждый кадр
    }
 
    private void OnDestroy()
    {
        systems?.Destroy(); // уничтожаем группу систем при уничтожении стартапа
        systems = null;
        ecsWorld?.Destroy() // и мир
        ecsWorld = null;
    }
}

Отлично, первый шаг завершен! Давайте создадим наш первый компонент и первую Init-систему, которая будет отвечать за создание сущности игрока и назначение ей определенных компонентов.

public struct Player // компонент игрока
{
    public Rigidbody playerRigidbody;
}
public class PlayerInitSystem : IEcsInitSystem
{
    private EcsWorld ecsWorld; // ссылка на мир инжектится автоматически

	  public void Init()
	  {
      	EcsEntity playerEntity = ecsWorld.NewEntity();
      	// entity.Get – возвращает существующий компонент или добавляет новый
        
        // компонент, необходимый для фильтрации и хранения данных игрока
      	ref var player = ref playerEntity.Get<Player>()
        // компонент с данными инпута
      	ref var inputData = ref playerEntity.Get<PlayerInputData>();
        
      	player.playerRigidbody = // где же взять Rigidbody игрока?
  	}
}

Как видите, мы столкнулись с проблемой. Нам нужно откуда-то взять данные, чтобы передать их в компоненты ECS. Как быть? Первое, что приходит в голову - найти игрока на сцене при помощи тега, названия или даже компонента MonoBehaviour, но давайте копнем глубже...

Эффективный способ разделения внешних данных

Давайте немного абстрагируемся от нашей проблемы и подумаем о том, как нам хранить внешние данные. Самый удобный способ управления – разделить их на три типа: Static Data, Scene Data и Runtime Data.

Static Data представляет собой конфигурацию игры – здесь вы можете сохранять ее настройки, которые не будут меняться в процессе выполнения программы. Это могут быть ссылки на префабы, которые должны быть инстанцированы в рантайме, размеры карты и прочие параметры. Проще всего реализовывать ее через ScriptableObject, ссылка на который хранится в классе EcsStartup.

Scene Data – это настройки конкретной сцены, которые могут отличаться друг от друга в разных сценах. В ней могут быть, например, спавн поинт игрока или других юнитов, название текущего уровня и другие настройки, индивидуальные для каждой сцены в проекте. По сути, это обычный MonoBehaviour класс, прикрепленный к Startup-объекту, а ссылка на него, аналогично со Static Data, хранится в классе EcsStartup.

Runtime Data – это обычный C# класс, куда нужно помещать ссылки на объекты, которые могут потребоваться в системах «здесь и сейчас», а также могут быть изменены в процессе игры. Например, это может быть карта клеток, ссылка на камеру, ссылка на сущность игрока и многое другое. Вы можете хранить эти данные в компонентах, но, тем не менее, если вы уверены, что какой-то единственный объект в своем роде, может понадобиться в различных частях проекта и представляет собою, даже что-то вроде сервиса, вам будет удобнее поместить его в этот вид данных.

Как же нам получить доступ ко всем этим данным в системах? Штатная реализация data injection в LeoECS позволяет нам в одну строку внедрить экземпляр класса в группу систем с помощью рефлексии. Чтобы обратиться к внедренному экземпляру, необходимо создать поле соответствующего типа в классе системы.

Вернемся к LeoECS

public class EcsStartup : MonoBehaviour
{
    // ссылки на StaticData и SceneData, которые необходимо навесить в редакторе
    // через инспектор
    public StaticData configuration;
    public SceneData sceneData;
    private EcsWorld ecsWorld;
    private EcsSystems systems;

	  private void Start()
    {
      	ecsWorld = new EcsWorld();
      	systems = new EcsSystems(ecsWorld);
        // создаем новый экземпляр RuntimeData.
      	RuntimeData runtimeData = new RuntimeData();

      	systems
          	.Add(new PlayerInitSystem())
          	// инжектим необходимые данные
          	.Inject(configuration)
          	.Inject(sceneData)
          	.Inject(runtimeData)
          	.Init();
    }


	  private void Update()
	  {
      	systems?.Run();
	  }

  	private void OnDestroy()
	  {
        systems?.Destroy();
        systems = null;
        ecsWorld?.Destroy();
        ecsWorld = null;
  	}
}
[CreateAssetMenu]
public class StaticData : ScriptableObject
{
    public GameObject playerPrefab;
}
public class SceneData : MonoBehaviour
{
    public Transform playerSpawnPoint;
}
public class RuntimeData
{
}

Давайте вернемся к PlayerInitSystem и попробуем вложить данные игрока в компоненты ECS.

public class PlayerInitSystem : IEcsInitSystem
{
    private EcsWorld ecsWorld;
	  private StaticData staticData; // мы можем добавить новые ссылки на StaticData и SceneData
	  private SceneData sceneData;

  	public void Init()
  	{
        EcsEntity playerEntity = ecsWorld.NewEntity();

      	ref var player = ref playerEntity.Get<Player>();
      	ref var inputData = ref playerEntity.Get<PlayerInputData>();
        
        // Спавним GameObject игрока
        GameObject playerGO = Object.Instantiate(staticData.playerPrefab, sceneData.playerSpawnPoint.position, Quaternion.identity);
      	player.playerRigidbody = playerGO.GetComponent<Rigidbody>();
  	}
}

Настало время реализовать систему передвижения игрока. Это можно реализовать множеством разных способов. К примеру, мы можем в одной системе ловить данные об инпуте, сохранять их в созданный компонент PlayerInputData, а в другой системе двигать персонажа на основе данных из этого компонента.

Для этого необходимо добавить поле в компонент PlayerInputData:

public struct PlayerInputData
{
    public Vector3 moveInput;
}

И создать Run-систему, которая каждый кадр будет обновлять значение этого компонента. Не забудьте добавить ее в группу систем в классе EcsStartup.

public class PlayerInputSystem : IEcsRunSystem
{
    private EcsFilter<PlayerInputData> filter; // фильтр, который выдаст нам все сущности, у которых есть компонент PlayerInputData
  
    public void Run()
    {
        foreach (var i in filter)
        {
            // Получаем значение компонента. Важен порядок констрейнтов фильтра - цифра после Get указывает номер компонента, значение которого он возвращает
            ref var input = ref filter.Get1(i)
            // Вызвать метод filter.Get2(i) не получится.
            // При изменении количества компонентов в фильтре вы меняете его класс, поэтому у фильтра с 1 констрейнтом нет методов Get2(), Get3(), etc.
          
            input.moveInput = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical")); // заполняем данные
        }
    }
}

Сразу после этой системы в стартапе должна идти другая система, которая двигает игрока.

Как опытные пользователи Unity, мы знаем, что при взаимодействии с физикой, нам лучше использовать цикл FixedUpdate с фиксированным тикрейтом, чтобы избежать каких-либо проблем с физическими взаимодействиями. Но у нас лишь одна группа систем и запускается она в цикле Update. Выход очевиден – необходимо создать новую группу систем, которая будет работать вызываться в FixedUpdate().

Кстати, не забудьте правильно настроить компонент Rigidbody у объекта и физический материал поверхности, по которой он будет двигаться.

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

public struct Player
{
    public Rigidbody playerRigidbody;
    public float playerSpeed;
}

Для инициализации, мы можем взять этот параметр из нашего ScriptableObject - StaticData. Для этого поместим в этот класс поле playerSpeed и зададим значение заранее в ассете:

[CreateAssetMenu]
public class StaticData : ScriptableObject
{
    public GameObject playerPrefab;
    public float playerSpeed;
}

В PlayerInitSystem мы возьмем значение из Static Data и положим его в компонент игрока:

player.playerSpeed = staticData.playerSpeed;

Теперь давайте создадим новую группу систем, которая будет запускаться в FixedUpdate:

public class EcsStartup : MonoBehaviour
{
    public StaticData configuration;
    public SceneData sceneData;

    private EcsWorld ecsWorld;
    private EcsSystems updateSystems;
    private EcsSystems fixedUpdateSystems; // новая группа систем

    private void Start()
    {
        ecsWorld = new EcsWorld();
        updateSystems = new EcsSystems(ecsWorld);
        fixedUpdateSystems = new EcsSystems(ecsWorld);
        RuntimeData runtimeData = new RuntimeData();

        updateSystems
            .Add(new PlayerInitSystem())
            .Add(new PlayerInputSystem())
            .Inject(configuration)
            .Inject(sceneData)
            .Inject(runtimeData);

        fixedUpdateSystems
            .Add(new PlayerMoveSystem()); // добавляем систему движения
      
        // Инициализируем группы систем
        updateSystems.Init();
        fixedUpdateSystems.Init();
    }

    private void Update()
    {
        updateSystems?.Run();
    }

    private void FixedUpdate()
    {
        fixedUpdateSystems?.Run(); // запускаем их каждый тик FixedUpdate()
    }

    private void OnDestroy()
    {
        updateSystems?.Destroy();
        updateSystems = null;
        fixedUpdateSystems?.Destroy();
        fixedUpdateSystems = null;
        ecsWorld?.Destroy();
        ecsWorld = null;
    }
}

Так будет выглядеть система движения игрока:

public class PlayerMoveSystem : IEcsRunSystem
{
    private EcsFilter<Player, PlayerInputData> filter;
    
    public void Run()
    {
        foreach (var i in filter)
        {
            ref var player = ref filter.Get1(i);
            ref var input = ref filter.Get2(i);

            Vector3 direction = (Vector3.forward * input.moveInput.z + Vector3.right * input.moveInput.x).normalized;
            player.playerRigidbody.AddForce(direction * player.playerSpeed);
        }
    }
}

Давайте теперь сделаем так, чтобы персонаж поворачивался сторону курсора. Задача весьма примитивна, мы можем реализовать эту механику в одной системе - PlayerRotationSystem. Мы могли бы получить доступ к Transform’у игрока через компонент Rigidbody, но давайте лучше прокинем его в компонент в init-системе игрока так же, как и прокинули сам Rigidbody.

public struct Player
{
    public Transform playerTransform; // новое поле в компоненте игрока
    public Rigidbody playerRigidbody;
    public float playerSpeed;
}

Добавим строчку в PlayerInitSystem:

player.playerTransform = playerGO.transform; 

Отлично. Нам также понадобится ссылка на камеру, давайте сохраним ее в SceneData - нужно предварительно назначить ее руками в сцене. 

public class SceneData : MonoBehaviour
{
    public Transform playerSpawnPoint;
    public Camera mainCamera;
}

Теперь давайте пополним наш список систем updateSystems новой системой, о которой говорили ранее:

public class PlayerRotationSystem : IEcsRunSystem
{
    private EcsFilter<Player> filter;
    private SceneData sceneData;

    public void Run()
    {
        foreach (var i in filter)
        {
            ref var player = ref filter.Get1(i);

            Plane playerPlane = new Plane(Vector3.up, player.playerTransform.position);
            Ray ray = sceneData.mainCamera.ScreenPointToRay(Input.mousePosition);
            if (!playerPlane.Raycast(ray, out var hitDistance)) continue;

            player.playerTransform.forward = ray.GetPoint(hitDistance) - player.playerTransform.position;
        }
    }
}

Теперь наш персонаж ходит и поворачивается, но для полноты картины не хватает анимаций. В нашей игре для корректной работы анимаций (когда нужно учитывать и поворот игрока, и вектор движения) необходимо создать Blend Tree в компоненте Animator, чтобы можно было легко смешивать анимации на основе каких-то параметров. Для этого достаточно 5 анимаций: Idle, ходьба вперед, ходьба назад, ходьба влево, ходьба вправо. Вы можете найти как модель персонажа, так и анимации к нему в Unity Asset Store. Я оставлю ссылку на ассет, который использовал, в конце статьи.

В Blend Tree создайте структуру анимаций, как на картинке:

Теперь осталось лишь создать систему для заполнения параметров Horizontal и Vertical на основе вектора движения (или пользовательского ввода) и поворота игрока.

Сначала получим ссылку на Animator так же, как и на Transform и Rigidbody - просто создайте новое поле в компоненте Player типа Animator и добавьте в init-системе игрока строчку, которая заполнит его.

public struct Player
{
    public Transform playerTransform;
    public Animator playerAnimator;
    public Rigidbody playerRigidbody;
    public float playerSpeed;
}
player.playerAnimator = playerGO.GetComponent<Animator>();

Теперь создадим систему, которая будет задавать параметры Аниматора:

public class PlayerAnimationSystem : IEcsRunSystem
{
    private EcsFilter<Player, PlayerInputData> filter;

    public void Run()
    {
        foreach (var i in filter)
        {
            ref var player = ref filter.Get1(i);
            ref var input = ref filter.Get2(i);
            
            float vertical = Vector3.Dot(input.moveInput.normalized, player.playerTransform.forward);
            float horizontal = Vector3.Dot(input.moveInput.normalized, player.playerTransform.right);
            player.playerAnimator.SetFloat("Horizontal", horizontal, 0.1f, Time.deltaTime);
            player.playerAnimator.SetFloat("Vertical", vertical, 0.1f, Time.deltaTime);
        }
    }
}

Отлично! Теперь наш персонаж движется, поворачивается в сторону курсора мыши и правильно двигает ногами в зависимости от направления движения!

Заключение

Мы разобрали основные аспекты работы с фреймворком LeoECS и сделали базовое движение персонажа с анимациями. В следующих частях мы разберем более продвинутые механики и рассмотрим примеры совместной работы “мира” ECS и MonoBehaviour-ов.

Ассет, который я использовал для проекта

Ссылка на репозиторий с проектом

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


  1. deeffoora
    15.08.2021 21:19

    Компонент Player слишком раздутый. Он агрегирует сразу большое количество серьезных классов. И не все системы используют эти классы. Получается, что системы декомпозированы, а компонент нет. И он, я так понимаю, будет разрастаться и разрастаться. И вскоре в нем будут располагаться данные, которые затрагивают половину логики игры. Это привносит опасность свалиться, в какой-то момент, в серьезную реструктуризацию. Что нивелирует заявленные возможности подхода ECS.


    1. supremestranger Автор
      15.08.2021 21:29
      +1

      Действительно, наличие подобных компонентов (своего рода "God class"-ов из ООП), которые будут только разрастаться и разрастаться, может лишить разработчика гибкости, которую дает паттерн ECS.

      Но я решил оставить его. Пока что нет необходимости разбивать компонент на несколько. Да, скорее всего, она появится позже на практике (в последующих частях, например), и вот тогда мы сможем без проблем это исправить благодаря еще одному преимуществу ECS - быстрому рефакторингу. Мы можем легко склеивать несколько компонентов/систем в одно целое или наоборот разбивать на несколько более атомарных частиц.

      Помещать, например, поле Health в компонент Player действительно не стоит, так как это свойство, которым обладают многие сущности в игре. Логика, которая связана с этим компонентом, будет работать для всех одинаково, а поэтому имеет смысл вынести Health в отдельный компонент.


  1. Stranger087
    04.09.2021 22:20

    Не работал с Лео, но код Get1(i) Get2(i) итд... выглядит ужасно. Ещё и с var.
    Представляю, как трудно будет разбираться в большом и сложном проекте который так написан, разработчику который пришел со стороны.


    1. supremestranger Автор
      04.09.2021 22:22
      +1

      На самом деле, к этому быстро привыкаешь. Особенно учитывая то, что в большинстве случаев сами фильтры небольшие.

      Можно посмотреть LeoEcsLite - здесь с этим проблем нет и четко видно, какой компонент ты получаешь.


  1. gavrozavr
    04.09.2021 22:22

    Я вот одного не понимаю. Ладно, ECS работает когда у нас, например, один тип логики для одного действия. Ну, как тут: мы ходим - мы обновляем позицию. А если их несколько? Ну, мы же можем управляться как напрямую, так и получать данные сервера, например. В одном случае заниматься интерполяцией и прочим надо, в другом - нет. И что, разбивать на 2 разных компонента?


    1. supremestranger Автор
      04.09.2021 22:29
      +1

      А какая разница? Просто добавьте корректирующую систему, которая внесет изменения посередине. Сетевые пакеты - еще одна система ввода, которая сгенерирует новый ивент или поправит данные напрямую.

      В этом и смысл работать ивентами - они могут возникать откуда угодно: хоть с клавомыши, хоть с тачпада, хоть от ИИ, хоть по сети.