Друзья, это первая статья по фреймворку 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)
Stranger087
04.09.2021 22:20Не работал с Лео, но код Get1(i) Get2(i) итд... выглядит ужасно. Ещё и с var.
Представляю, как трудно будет разбираться в большом и сложном проекте который так написан, разработчику который пришел со стороны.supremestranger Автор
04.09.2021 22:22+1На самом деле, к этому быстро привыкаешь. Особенно учитывая то, что в большинстве случаев сами фильтры небольшие.
Можно посмотреть LeoEcsLite - здесь с этим проблем нет и четко видно, какой компонент ты получаешь.
gavrozavr
04.09.2021 22:22Я вот одного не понимаю. Ладно, ECS работает когда у нас, например, один тип логики для одного действия. Ну, как тут: мы ходим - мы обновляем позицию. А если их несколько? Ну, мы же можем управляться как напрямую, так и получать данные сервера, например. В одном случае заниматься интерполяцией и прочим надо, в другом - нет. И что, разбивать на 2 разных компонента?
supremestranger Автор
04.09.2021 22:29+1А какая разница? Просто добавьте корректирующую систему, которая внесет изменения посередине. Сетевые пакеты - еще одна система ввода, которая сгенерирует новый ивент или поправит данные напрямую.
В этом и смысл работать ивентами - они могут возникать откуда угодно: хоть с клавомыши, хоть с тачпада, хоть от ИИ, хоть по сети.
deeffoora
Компонент Player слишком раздутый. Он агрегирует сразу большое количество серьезных классов. И не все системы используют эти классы. Получается, что системы декомпозированы, а компонент нет. И он, я так понимаю, будет разрастаться и разрастаться. И вскоре в нем будут располагаться данные, которые затрагивают половину логики игры. Это привносит опасность свалиться, в какой-то момент, в серьезную реструктуризацию. Что нивелирует заявленные возможности подхода ECS.
supremestranger Автор
Действительно, наличие подобных компонентов (своего рода "God class"-ов из ООП), которые будут только разрастаться и разрастаться, может лишить разработчика гибкости, которую дает паттерн ECS.
Но я решил оставить его. Пока что нет необходимости разбивать компонент на несколько. Да, скорее всего, она появится позже на практике (в последующих частях, например), и вот тогда мы сможем без проблем это исправить благодаря еще одному преимуществу ECS - быстрому рефакторингу. Мы можем легко склеивать несколько компонентов/систем в одно целое или наоборот разбивать на несколько более атомарных частиц.
Помещать, например, поле Health в компонент Player действительно не стоит, так как это свойство, которым обладают многие сущности в игре. Логика, которая связана с этим компонентом, будет работать для всех одинаково, а поэтому имеет смысл вынести Health в отдельный компонент.