Привет всем. Случайно вспомнил про булку, зашел и оказывается тут еще есть активность.
Вступление:
В некоторых темах увидел тред где Crystal с "процедурными" знаниями Блитза в очередной раз пытается сделать свой фаллаут, но только уже на Юнити и как ему сложно. Увидел как Кирпи4 спрашивает, как новичку влиться в геймдев. И вспомнилось былое время, когда у меня тоже не было знаний, но было много вопросов и как сложно было раскуривать ооп.
Но
спасение есть. Когда-то давно на конференции Юнити представили
Entitas фреймворк с ECS для Юнити и после этого эта тема всё больше и больше форсится, а многие мелкие студии уже давно перешли на ECS так как по уровню сложности это примерно как визуальное программирование и любой новичок крайне быстро может во всем разобраться и влиться в геймдевчик.
Самих
ECS множество со своими плюсами, минусами и интеграцией в Юнити (
DOTS/uECS). Но я напишу тут пример кода на
LeoECS так как он максимально простой, позволяет делать "как хочешь" в отличии от
DOTS/uECS, но при этом достаточно быстрый, изначально не привязан к Юнити и без кодогенерации как тот же
Entitas.
Что к чему:
Краткая суть почему
ECS это хорошо заключается в том, что практически не нужно уметь кодить, не нужно знать тонну паттернов и прочих сложностей. Все просто, как и в визуальном программировании, но только там соединяем ноды, а тут печатаем кусочки кода.
Так же использование
ECS идеально подходит для небольших игр на мобилки типа Match3, но и большие тоже можно писать.
Сам
ECS из себя представляет:
World - "мир" в котором всё крутится
System - системы, которые обрабатывают данные
Entity - фактически контейнер, который содержит в себе компоненты
Component - сам компонент представляющий собой структуру в которой храниться несколько значений.
*собственно Entity и Component это тоже самое, что и в Юнити GameObject и Component.
Но только в Юнити свернули не туда
Ещё
ECS можно представить просто как конвейер (
World) по которому движутся коробки (
Entity) в которых что-то лежит (
Component), а рядом стоящие работники (
System) берут эти коробки (
Entity), что-то делают с ними или c их содержимым (
Component), а затем или возвращают коробку (
Entity) обратно на конвейер (
World) или вовсе "выбрасывают в мусорку".
Особенность всего этого в том, что
System,
Entity и
Component ничего не знают друг о друге и вся магия происходит благодаря
System.EcsFilter<T, ..>, который берет все
Entity в текущем "мире", сортирует их по наличию запрашиваемых компонентов на них и возвращает пользователю грубо говоря массив с
Entity.
Ресурсы:
Для начала нам нужно скачать
ECS и два модуля к нему для связи с Юнити (это кстати единственная сложность в использовании стороннего
ECS).
LeoECS:
https://github.com/Leopotam/ecs
(тут так же есть краткая
документация, ссылки на другие модули, ссылки на примеры кода и на некоторые игры сделанные на этом ECS)
Unity integration:
https://github.com/Leopotam/ecs-unityintegration
(это нужно просто для дебага ECS в редакторе Юнити)
Service Locator:
https://github.com/Leopotam/globals
(это фактически синглтон для вызова ECS из классов MonoBehaviour)
Простой пример:
После добавления ресурсов с гитхаба в Юнити нужно нажать правой кнопной мышки например на папку
Assets и выбрать "
Create >
LeoECS >
Create Startup template". Это создаст
MonoBehaviour класс с инициализацией
ECS.
Данный юнити-компонент нужно добавить на игровой объект в сцене.
using Leopotam.Ecs;
using LeopotamGroup.Globals;
using UnityEngine;
namespace Example {
internal class EcsStartup : MonoBehaviour {
private EcsWorld world = default;
private EcsSystems systems = default;
// Это нужно для интеграции с Юнити так как сам ECS изначально
// никак не связан с ним.
// В небольшом комьюнити по LeoECS принято использовать этот шаблон где:
// GameData используется для реалтайм-данных так как System, Entity и Component
// ничего не знают друг о друге.
// [System.Serializable]
// public class GameData {
// public int shootCount = 0;
// public int collisionsCount = 0;
// }
[SerializeField] private GameData gameData = new GameData();
// SceneData наследуется от MonoBehaviour и содержит в себе ссылки
// на объекты сцены. Например это камера и какие-то
// заранее созданные объекты. Я в этом примере заранее создал
// игрока и сделал ссылку на его игровой объект.
// public class SceneData : MonoBehaviour {
// ссылка на объект игрока, который уже находится на сцене
// public PlayerView PlayerView;
// //public Camera mainCamera;
// //...
// }
[SerializeField] private SceneData sceneData = default;
// Config наследуется от ScriptableObject и служит для хранения
// всяких настроек, ссылок на префабы, которые в какой-то момент
// нужно будет создать. Так же в нем можно хранить ссылки на
// другие ScriptableObject с конфигами.
// Именно этот класс сугубо только для удобства. Данные можно
// так же классическим способом хранить и на MonoBehaviour игрока, врага.
//[CreateAssetMenu]
// public class Config : ScriptableObject {
// Данные игрока, которые можно изменять в реальном времени
// и которые сохраняться после выхода из игрового режима в редакторе
// public float PlayerMoveSpeed = 1f;
// public float PlayerRotateSpeed = 50f;
// [Space]
// public ProjectileView ProjectilePrefab = default;
// public float ProjectileStartSpeed;
// }
[SerializeField] private Config config = default;
private void Start() {
world = new EcsWorld();
systems = new EcsSystems(world);
// Это пак Service Locator, который является аналогом синглтона.
// Для примера сохраним ссылку на реалтайм-данные
Service<GameData>.Set(gameData);
// Это пак Unity integration, который нужнен для дебага ECS в сцене.
// Так же именно это создаёт некоторое количество аллокаций так как
// для наглядности при добавлении Entity или компонентов изменяет
// имя скрытых игровых объектов, которые отображаются в дебаге.
// Скрин в аттаче
#if UNITY_EDITOR
Leopotam.Ecs.UnityIntegration.EcsWorldObserver.Create(world);
Leopotam.Ecs.UnityIntegration.EcsSystemsObserver.Create(systems);
#endif
// Это хендл для систем. Тут в список добавляются системы.
systems
// Сам процесс добавления систем. Если не добавлять систему или если
// что-то закомментить, то у нас просто пропадёт часть функционала
// без каких либо ошибок. Один из серьезных плюсов.
.Add(new PlayerInitSystem())
.Add(new PlayerInputSystem())
.Add(new PlayerMoveSystem())
.Add(new PlayerRotateSystem())
// Удаляет все экземпляры указанного компонента со всех Entity и со
// всех тут указанных систем.
// Этот метод специфичен и иногда при неправильном добавлении
// систем создаёт проблемы. Так как если сначала в список
// добавить систему с обработкой компонента, а ниже добавить
// систему с генерацией компонента, то естественно на следующем
// цикле этот компонент уже будет удален и система с обработкой
// компонента уже не будет работать.
.OneFrame<PlayerInputEvent>()
// Это Dependency injection данных во все системы в текущем EcsSystems
.Inject(gameData)
.Inject(sceneData)
.Inject(config)
// Инициализация EcsSystems
.Init();
}
// Тут вызывается цикл EcsSystems. Если нужно использовать FixedUpdate,
// то нужно создать еще одну систему и по аналогии вызвать.
// Важно помнить, что Entity с компонентами хранятся в EcsWorld и добавление
// новых или удаление EcsSystems повлияет только на функционал.
private void Update() {
systems?.Run();
}
// Очистка системы и "мира".
// При добавлении новых EcsSystems в мир нужно будет не много переделать
// код дабы сначала удалить все EcsSystems, а потом и сам "мир".
private void OnDestroy() {
if (systems != null) {
systems.Destroy();
systems = null;
world.Destroy();
world = null;
}
}
}
}
В идеале система должна выполнять минимально количество кода. Из-за этого количество систем может быть и 10 и 100 и соответственно лучше сразу называть их понятным для себя образом.
Первая система называется у нас
PlayerInitSystem. В ней мы создаем
Entity и связываем с игроком на сцене.
using Leopotam.Ecs;
using UnityEngine;
internal class PlayerInitSystem : IEcsInitSystem {
// Тот самый Dependency injection.
// Для EcsWorld выполняется автоматически, а все остальное нужно добавлять
// руками через .Inject(..)
private readonly EcsWorld ecsWorld = default;
private readonly SceneData sceneData = default;
// IEcsInitSystem.Init для вызова на старте игры или при вызове EcsSystem.Init()
public void Init() {
// Получаем ссылку на PlayerView
var view = sceneData.PlayerView;
// Создаем новый Entity. Все Entity находятся в пуле. Если свободный Entity
// уже есть, то тогда берется из пула
var entity = ecsWorld.NewEntity();
// Добавляем в Entity компонент PlayerRef и сохраняем ссылку на PlayerView
// из которого можно будет получить ссылку на transform и другие компоненты
entity.Get<PlayerRef>().View = view;
// В сам PlayerView тоже сохраняем ссылку на Entity
view.Entity = entity;
}
}
public class PlayerView : MonoBehaviour {
public EcsEntity Entity;
// Фактически PlayerView не нужен, достаточно только в ECS-компоненте
// сохранить ссылку на transform игрока.
// Но при таком подходе потом при вызове любого MonoBehaviour события
// допустим OnCollisionEnter можно будет сразу добавить нужный компонент
// на Entity игрока
// private void OnCollisionEnter(Collision other) {
// Entity.Get<PlayerCollideEvent>();
// }
}
internal struct PlayerRef {
public PlayerView View;
}
Далее у нас система
PlayerInputSystem в которой обрабатываем
Input. Вообще нужно делать по другому, но для примера будет достаточно.
using Leopotam.Ecs;
using UnityEngine;
internal class PlayerInputSystem : IEcsRunSystem {
// Тут в EcsFilter<T> мы указываем с каким компонентом нам нужны Entity.
// Компонентов можно задать больше
// Так же можно использовать EcsFilter<T, ..>.Exclude<T, ..> для исключения
// определенных компонентов из результата
private readonly EcsFilter<PlayerRef> filter = default;
// IEcsRunSystem.Run этот метод вызывается в каждом цикле
public void Run() {
// В данном случае это нужно только для того, что бы не вызывался
// лишний раз Input и создание Vector2 если вдруг у нас пустой фильтр
if(filter.IsEmpty())
return;
var h = Input.GetAxisRaw("Horizontal");
var v = Input.GetAxisRaw("Vertical");
var direction = new Vector2(h, v).normalized;
// Проходим по всем элементам. Тут за foreach можно не переживать
// Код с Input'ом можно перенести и сюда, но это будут лишние
// вычисления если у нас например в фильтре будет не 1, а 1000 Entity
if (direction.sqrMagnitude > 0f) {
foreach (var i in filter) {
// Получаем ссылку на Entity данной итерации и добавляем
// компонент, который заканчивается на Event. Таким образом
// мы только для себя обозначаем, что этот компонент должен
// быть удален после использования всеми системами.
// В данном случае он будет удален в конце главного цикла
// через метод .OneFrame<PlayerInputEvent>()
filter.GetEntity(i).Get<PlayerInputEvent>().Value = direction;
}
}
}
}
internal struct PlayerInputEvent {
public Vector2 Value;
}
Система
PlayerMoveSystem будет двигать все
Entity с определенным количеством компонентов
internal class PlayerMoveSystem : IEcsRunSystem {
// Как видим тут мы фильтруем по наличию компонентов PlayerRef
// и PlayerInputEvent, которые в данном примере должны быть только
// у Entity игрока.
private readonly EcsFilter<PlayerRef, PlayerInputEvent> filter = default;
// DI конфига
private readonly Config config = default;
public void Run() {
// Тут уже не нужны никакие доп. вычисления типа Input как в прошлой системе
foreach (var i in filter) {
// Обратите внимание на "ref". Так как у нас компоненты
// являются "struct", мы должны получать ссылку на них
// именно через ref иначе мы получим копию, которая
// ничего не изменит и удалится сразу же после выхода
// из этого метода.
// Через Get1, Get2, GetN мы получаем компонент из Entity.
// Нумерация компонентов такая же как в EcsFilter
ref var playerRef = ref filter.Get1(i);
ref var inputEvent = ref filter.Get2(i);
// Получаем ссылку на transform игрока через PlayerView,
// который до этого сохранили в компоненте PlayerRef
// Так же получаем из конфига скорость игрока,
// которую можно менять в реальном времени.
// Если у нас будет 1000 игроков в фильтре,
// то скорость изменится для всех.
var tr = playerRef.View.transform;
tr.position += tr.forward * inputEvent.Value.y * config.PlayerMoveSpeed * Time.deltaTime;
}
}
}
Следующая система фактически копи-паст предыдущей и может возникнуть вопрос нафига этот лишний код? А ответ тот же, что и был выше - если отключить систему или если закоментить её подключение в коде, то в игре просто пропадёт функционал, который содержится в системе и никаких ошибок не будет.
internal class PlayerRotateSystem : IEcsRunSystem {
private readonly EcsFilter<PlayerRef, PlayerInputEvent> filter = default;
private readonly Config config = default;
public void Run() {
foreach (var i in filter) {
ref var playerRef = ref filter.Get1(i);
ref var inputEvent = ref filter.Get2(i);
// Все тоже самое, но только мы тут разворачиваем игрока
// Конечно можно объединить в одну, но тогда мы теряем
// ту самую гибкость ECS
var tr = playerRef.View.transform;
tr.Rotate(Vector3.up, inputEvent.Value.x * config.PlayerRotateSpeed * Time.deltaTime);
}
}
}