Создание шутера с LeoECS. Часть 1

—

от автора

Друзья, это первая статья по фреймворку 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-ов.

Туториал подготовлен в соавторстве с Владимиром Роттердамским

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/573028/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *