Друзья, это первая статья по фреймворку 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-ов.
Туториал подготовлен в соавторстве с Владимиром Роттердамским
