Друзья, это начало нового цикла статей про создание игры жанра dungeon crawler с использованием фреймворка LeoECS Lite, и его задача – помочь вам быстро разобраться, как на практике применить LeoECS Lite для разработки игр на Unity и решить некоторые виды проблем.

LeoECS Lite - новая, более легковесная версия фреймворка LeoECS, о котором отдельно написаны туториалы. Пусть слово "Lite" не вводит вас в заблуждение – она подчеркивает именно легковесность фреймворка, а не простоту использования. Хотя сам по себе он простой, он может быть непривычным для тех, кто привык к API классической версии.
Список ключевых изменений в API
Чтобы добавить/удалить компонент у сущности, вам необходимо сначала получить доступ к пулу компонентов.
Dependency Injection через рефлекшн убран из ядра, поэтому вы не можете сразу пользоваться ссылками на мир, фильтры или новые пулы в экземплярах систем - нужно или запрашивать их в рантайме, или кешировать заранее.
Сущности теперь в чистом виде представляют собой обычные int'ы. Чтобы сохранить их где-то и иметь возможность проверить, не уничтожена ли энтити, нужно паковать их через мир.
Про остальные изменения можно прочитать в README репозитория.
С другой стороны, легковесность фреймворка заключается в том, что простым стало именно ядро. Оно стало модульным - большая часть фишек переехала в расширения, которые можно подключать опционально. С их помощью можно сделать приятный API, напоминающий классику, и вот какие мы будем использовать при создании игры: ecslite-di, ecslite-extended-systems, ecslite-unityeditor, ecslite-unity-ugui.
Итак, давайте перейдем к практике и начнем разработку с создания старптап-класса.
namespace Client { sealed class Game : MonoBehaviour { [SerializeField] SceneData _sceneData; [SerializeField] Configuration _configuration; EcsSystems _systems; void Start () { var world = new EcsWorld (); _systems = new EcsSystems (world); _systems #if UNITY_EDITOR .Add (new Leopotam.EcsLite.UnityEditor.EcsWorldDebugSystem ()) #endif .Inject (_sceneData) .Init (); } void Update () { _systems?.Run (); } void OnDestroy () { _systems?.Destroy (); _systems?.GetWorld ()?.Destroy (); _systems = null; } } }
Для тех, кто знаком с LeoECS, изменений здесь не так уж и много. Мы будем использовать простой MonoBehaviour экземпляр класса SceneData, в котором будут храниться данные, связанные со сценой, а также класс Configuration, который является экземпляром Scriptable Object'а.
namespace Client { sealed class SceneData : MonoBehaviour { } }
namespace Client { [CreateAssetMenu] sealed class Configuration : ScriptableObject { // Ширина и высота сетки. public int GridWidth; public int Gridheight; } }
Первым делом нам нужна возможность создать карту клеток. Мы можем создать простой MonoBehaviour класс, который будет добавлен к префабу клетки. Благодаря нему мы сможем располагать клетки в сцене с определенным интервалом. При этом они будут магнититься к нужным местам, вычисляя правильные координаты.
namespace Client { #if UNITY_EDITOR [ExecuteAlways] // Код ниже должен исполняться всегда. [SelectionBase] // Если вы кликнете на внутреннюю запчасть префаба, то выделится именно этот объект #endif sealed class CellView : MonoBehaviour { public Transform Transform; public float XzStep = 3f; public float YStep = 1f; void Awake () { Transform = transform; } #if UNITY_EDITOR void Update () { if (!Application.isPlaying && Transform.hasChanged) { var newPos = Vector3.zero; var curPos = Transform.localPosition; newPos.x = Mathf.RoundToInt (curPos.x / XzStep) * XzStep; newPos.z = Mathf.RoundToInt (curPos.z / XzStep) * XzStep; newPos.y = Mathf.RoundToInt (curPos.y / YStep) * YStep; Transform.localPosition = newPos; // Магнитим клетку к сетке. } } void OnDrawGizmos () { var selected = Selection.Contains (gameObject); // Проверяем, выделен ли объект Gizmos.color = selected ? Color.green : Color.cyan; // Если выделен, цвет гизмос будет зеленый, если нет - голубой var yAdd = selected ? 0.02f : 0f; // Если выделен, то слегка приподнимем клетку, чтобы выделить ее var curPos = Transform.localPosition; // Начинаем вычислять координаты квадрата var leftDown = curPos - Vector3.right * XzStep / 2 - Vector3.forward * XzStep / 2 + Vector3.up * yAdd; var leftUp = curPos - Vector3.right * XzStep / 2 + Vector3.forward * XzStep / 2 + Vector3.up * yAdd; var rightDown = curPos + Vector3.right * XzStep / 2 - Vector3.forward * XzStep / 2 + Vector3.up * yAdd; var rightUp = curPos + Vector3.right * XzStep / 2 + Vector3.forward * XzStep / 2 + Vector3.up * yAdd; Gizmos.DrawLine (leftDown, leftUp); // Рисуем квадрат Gizmos.DrawLine (leftUp, rightUp); Gizmos.DrawLine (rightUp, rightDown); Gizmos.DrawLine (rightDown, leftDown); Gizmos.DrawSphere (curPos, 0.1f); } #endif } }
Как видите, мы также сделали удобное отображение клеток в окне редактора с Gizmos.

Нам нужно будет проинициализировать карту в самом начале, создав сущности для каждой клетки, на основе данных с уровня. То есть, левел дизайнер делает карту, размещает клетки, а мы в начале игры создаем сущности клеток и добавляем к ним компоненты.
Давайте вернемся к нашему классу SceneData и немного поменяем его:
namespace Client { sealed class SceneData : MonoBehaviour { public CellView[] Cells; #if UNITY_EDITOR [ContextMenu ("Find Cells")] void FindCells () { Cells = FindObjectsOfType<CellView> (); Debug.Log ($"Successfully found {Cells.Length} cells!"); } #endif } }
Благодаря этому контекстному меню мы сможем заранее найти и сохранить все клетки в массив. Конечно, можно делать это и на старте игры, но зачем его замедлять, если можно потратить пару минут времени на кеширование и ускорение старта?
Для того, чтобы хранить сетку, лучше создать отдельный сервис.
namespace Client { sealed class GridService { readonly int[] _cells; readonly int _width; readonly int _height; public GridService (int width, int height) { _cells = new int[width * height]; _width = width; _height = height; } public (int, bool) GetCell (Int2 coords) { var entity = _cells[_width * coords.Y + coords.X] - 1; return (entity, entity >= 0); } public void AddCell (Int2 coords, int entity) { _cells[_width * coords.Y + coords.X] = entity + 1; } } }
Также создадим структуру Int2 для хранения двух целых чисел. Она будет более простая, чем штатный Vector2Int.
namespace Client { struct Int2 { public int X; public int Y; public Int2 (int x, int y) { X = x; Y = y; } public static Int2 operator + (Int2 a, Int2 b) { return new Int2 (a.X + b.X, a.Y + b.Y); } public static Int2 operator * (Int2 a, int multiplier) { return new Int2 (a.X * multiplier, a.Y * multiplier); } } }
Можно, конечно, хранить все данные в компоненте на какой-то сущности, но удобнее будет создать сервис с API для добавления и получения клеток.
namespace Client { // Компонент клетки. struct Cell { public CellView View; } }
Теперь давайте создадим инит-систему, которая будет заполнять данные сервиса.
namespace Client { sealed class GridInitSystem : IEcsInitSystem { readonly EcsCustomInject<GridService> _gs = default; readonly EcsCustomInject<SceneData> _sceneData = default; readonly EcsPoolInject<Cell> _cellPool = default; public void Init (EcsSystems systems) { var world = _cellPool.Value.GetWorld (); for (var i = 0; i < _sceneData.Value.Cells.Length; i++) { var cellView = _sceneData.Value.Cells[i]; var entity = world.NewEntity (); ref var cell = ref _cellPool.Value.Add (entity); var position = cellView.transform.position; var x = (int) (position.x / cellView.XzStep); var y = (int) (position.z / cellView.XzStep); cell.View = cellView; _gs.Value.AddCell (new Int2 (x, y), entity); } } } }
Отлично, мы соорудили сервис карты и собрали в нем данные о клетках на сцене.
Теперь давайте создадим отдельный сервис TimeService, - некую абстракцию от времени Unity - данные которого будем заполнять в начале каждого кадра.
namespace Client { sealed class TimeService { public float Time; public float DeltaTime; public float UnscaledDeltaTime; public float UnscaledTime; } }
namespace Client { sealed class TimeSystem : IEcsRunSystem { readonly EcsCustomInject<TimeService> _ts = default; public void Run (EcsSystems systems) { _ts.Value.Time = Time.time; _ts.Value.UnscaledTime = Time.unscaledTime; _ts.Value.DeltaTime = Time.deltaTime; _ts.Value.UnscaledDeltaTime = Time.unscaledDeltaTime; } } }
Не забудьте обновить стартап, добавив туда создание экземпляров сервисов и новых систем:
namespace Client { sealed class Game : MonoBehaviour { [SerializeField] SceneData _sceneData; [SerializeField] Configuration _configuration; EcsSystems _systems; void Start () { var world = new EcsWorld (); _systems = new EcsSystems (world); var ts = new TimeService (); var gs = new GridService (_configuration.GridWidth, _configuration.Gridheight); _systems .Add (new GridInitSystem ()) .Add (new TimeSystem ()) #if UNITY_EDITOR .Add (new Leopotam.EcsLite.UnityEditor.EcsWorldDebugSystem ()) #endif .Inject (ts, gs, _sceneData) .Init (); } void Update () { _systems?.Run (); } void OnDestroy () { _systems?.Destroy (); _systems?.GetWorld ()?.Destroy (); _systems = null; } } }
Теперь мы можем заняться спауном игрока.
namespace Client { sealed class PlayerInitSystem : IEcsInitSystem { readonly EcsPoolInject<Unit> _unitPool = default; readonly EcsPoolInject<ControlledByPlayer> _controlledByPlayerPool = default; public void Init (EcsSystems systems) { var playerEntity = _unitPool.Value.GetWorld ().NewEntity (); ref var unit = ref _unitPool.Value.Add (playerEntity); _controlledByPlayerPool.Value.Add (playerEntity); var playerPrefab = Resources.Load ("Player"); var playerGo = (GameObject) Object.Instantiate (playerPrefab, Vector3.zero, Quaternion.identity); unit.Direction = 0; unit.CellCoords = new Int2 (0, 0); unit.Transform = playerGo.transform; unit.Position = Vector3.zero; unit.Rotation = Quaternion.identity; // тестовые значения. unit.MoveSpeed = 3f; unit.RotateSpeed = 10f; } } }
namespace Client { struct Unit { public Direction Direction; public Int2 CellCoords; public Transform Transform; public Vector3 Position; public Quaternion Rotation; public float MoveSpeed; public float RotateSpeed; } }
namespace Client { struct ControlledByPlayer { } }
Как вы заметили, к сущности игрока добавлены компоненты Unit и ControlledByPlayer. Почему именно так?
Дело в том, что в нашей игре и игрок, и монстры будут двигаться по одинаковым правилам. Логика (код) перемещения по клеткам будет одинаковый для всех юнитов. Единственное, что будет отличаться - источник команд. Юнит игрока будет принимать команды от пользовательского ввода, враги - от ИИ.

И даже если вы в силу неопытности этого не заметили бы и написали отдельно код и для игрока, и для врагов, повторяющиеся строки натолкнут вас на мысль о том, что имеет смысл вынести их в отдельный блок кода. И с ECS это все будет проще и быстрее отрефакторить, так как каждая сущность - набор компонентов.
Давайте сделаем аж три способа управления персонажем: через клавиатуру, через кнопки и через мышь. Но все по порядку.
Направления вперед-назад будут использоваться для движения, влево-вправо для поворотов.
Начнем с простого. Управление через клавиатуру. Создадим отдельную систему для этого.
namespace Client { sealed class UserKeyboardInputSystem : IEcsRunSystem { readonly EcsFilterInject<Inc<Unit, ControlledByPlayer>> _units = default; readonly EcsPoolInject<MoveCommand> _moveCommandPool = default; readonly EcsPoolInject<RotateCommand> _rotateCommandPool = default; public void Run (EcsSystems systems) { foreach (var entity in _units.Value) { var vertInput = Input.GetAxisRaw (Idents.Input.VerticalAxis); var horizInput = Input.GetAxisRaw (Idents.Input.HorizontalAxis); switch (vertInput) { case 1f: _moveCommandPool.Value.Add (entity); break; case -1f: ref var moveCmd = ref _moveCommandPool.Value.Add (entity); moveCmd.Backwards = true; break; } if (horizInput != 0f) { ref var rotCmd = ref _rotateCommandPool.Value.Add (entity); rotCmd.Side = (int) horizInput; } } } } }
namespace Client { // Событие о команде движения struct MoveCommand { public bool Backwards; } }
namespace Client { // И о повороте struct RotateCommand { public int Side; } }
Чтобы сохранить строки и иметь возможность быстро менять их везде, создадим статический класс Idents.
namespace Client { static class Idents { public static class Input { public const string VerticalAxis = "Vertical"; public const string HorizontalAxis = "Horizontal"; } } }
Теперь займемся настройкой UI.

Создадим 4 кнопки и повесим на них нужные компоненты для обработки событий UI.

На корневой объект UI нужно будет добавить компонент EcsUguiEmitter. Давайте проинициализируем его в коде.
... [SerializeField] EcsUguiEmitter _uguiEmitter; // новая строка в классе Game ...
... .Inject (ts, gs, _sceneData) .InjectUgui (_uguiEmitter) .Init (); ...
В EcsLite рекомендуется использовать отдельный мир для короткоживущих энтити-ивентов, так как каждый мир имеет размер maxEntitiesCount * poolsCount. То есть, если у вас в мире 100 тысяч сущностей для юнитов, и вы вдруг создаете одну сущность-ивент с компонентом "Click", то для этого компонента будет создан пул с огромным размером, что в конечном итоге приведет к нерациональному распределению памяти.
namespace Client { sealed class Game : MonoBehaviour { [SerializeField] SceneData _sceneData; [SerializeField] Configuration _configuration; [SerializeField] EcsUguiEmitter _uguiEmitter; EcsSystems _systems; void Start () { var world = new EcsWorld (); _systems = new EcsSystems (world); var ts = new TimeService (); var gs = new GridService (_configuration.GridWidth, _configuration.Gridheight); _systems .Add (new GridInitSystem ()) .Add (new TimeSystem ()) .Add (new PlayerInitSystem ()) .DelHere<MoveCommand> () .Add (new UserKeyboardInputSystem ()) .AddWorld (new EcsWorld (), Idents.Worlds.Events) #if UNITY_EDITOR .Add (new Leopotam.EcsLite.UnityEditor.EcsWorldDebugSystem ()) .Add (new Leopotam.EcsLite.UnityEditor.EcsWorldDebugSystem (Idents.Worlds.Events)) #endif .Inject (ts, gs, _sceneData) .InjectUgui (_uguiEmitter, Idents.Worlds.Events) .Init (); } void Update () { _systems?.Run (); } void OnDestroy () { _systems?.Destroy (); _systems?.GetWorld ()?.Destroy (); _systems = null; } } }
Добавим название мира для событий и названия кнопок в класс Idents.
namespace Client { static class Idents { public static class Input { public const string VerticalAxis = "Vertical"; public const string HorizontalAxis = "Horizontal"; } public static class Worlds { public const string Events = "Events"; } public static class Ui { public const string Forward = "Forward"; public const string Back = "Back"; public const string Left = "Left"; public const string Right = "Right"; } } }
Теперь создадим систему, которая будет ловить события с кнопок.
namespace Client { sealed class UserButtonsInputSystem : EcsUguiCallbackSystem { readonly EcsFilterInject<Inc<Unit, ControlledByPlayer>> _units = default; readonly EcsPoolInject<MoveCommand> _moveCommandPool = default; readonly EcsPoolInject<RotateCommand> _rotateCommandPool = default; [Preserve] [EcsUguiClickEvent (Idents.Ui.Forward, Idents.Worlds.Events)] void OnClickForward (in EcsUguiClickEvent e) { foreach (var entity in _units.Value) { _moveCommandPool.Value.Add (entity); } } [Preserve] [EcsUguiClickEvent (Idents.Ui.Back, Idents.Worlds.Events)] void OnClickBack (in EcsUguiClickEvent e) { foreach (var entity in _units.Value) { ref var moveCmd = ref _moveCommandPool.Value.Add (entity); moveCmd.Backwards = true; break; } } [Preserve] [EcsUguiClickEvent (Idents.Ui.Left, Idents.Worlds.Events)] void OnClickLeft (in EcsUguiClickEvent e) { foreach (var entity in _units.Value) { ref var rotCmd = ref _rotateCommandPool.Value.Add (entity); rotCmd.Side = -1; } } [Preserve] [EcsUguiClickEvent (Idents.Ui.Right, Idents.Worlds.Events)] void OnClickRight (in EcsUguiClickEvent e) { foreach (var entity in _units.Value) { ref var rotCmd = ref _rotateCommandPool.Value.Add (entity); rotCmd.Side = 1; } } } }
Осталось лишь создать систему свайпов. Создадим отдельный полноэкранный невидимый виджет для этого. Не забудьте, что нужно поместить его под кнопки, иначе клики в них не пойдут.

И новую систему:
namespace Client { sealed class UserSwipeInputSystem : EcsUguiCallbackSystem { readonly EcsFilterInject<Inc<Unit, ControlledByPlayer>> _units = default; readonly EcsPoolInject<MoveCommand> _moveCommandPool = default; readonly EcsPoolInject<RotateCommand> _rotateCommandPool = default; const float MinSwipeMagnitude = 0.2f; Vector2 _lastTouchPos = default; [Preserve] [EcsUguiDownEvent (Idents.Ui.TouchListener, Idents.Worlds.Events)] void OnDownTouchListener (in EcsUguiDownEvent e) { _lastTouchPos = e.Position; } [Preserve] [EcsUguiUpEvent (Idents.Ui.TouchListener, Idents.Worlds.Events)] void OnUpTouchListener (in EcsUguiUpEvent e) { var swipe = e.Position - _lastTouchPos; var swipeHorizontal = swipe.x / Screen.width; var swipeVertical = swipe.y / Screen.height; if (Mathf.Abs (swipeVertical) >= MinSwipeMagnitude) { foreach (var entity in _units.Value) { ref var moveCmd = ref _moveCommandPool.Value.Add (entity); moveCmd.Backwards = swipeVertical < 0f; break; } } else if (Mathf.Abs (swipeHorizontal) >= MinSwipeMagnitude) { foreach (var entity in _units.Value) { ref var rotCmd = ref _rotateCommandPool.Value.Add (entity); var side = swipeHorizontal > 0f ? 1 : -1; rotCmd.Side = side; } } } } }
Теперь создадим системы для движения и поворотов.
namespace Client { sealed class UnitStartMovingSystem : IEcsRunSystem { readonly EcsFilterInject<Inc<Unit, MoveCommand>, Exc<Animating>> _units = default; readonly EcsPoolInject<Animating> _animatingPool = default; readonly EcsPoolInject<Moving> _movingPool = default; readonly EcsPoolInject<Cell> _cellPool = default; readonly EcsCustomInject<GridService> _gs = default; public void Run (EcsSystems systems) { foreach (var entity in _units.Value) { ref var unit = ref _units.Pools.Inc1.Get (entity); ref var cmd = ref _units.Pools.Inc2.Get (entity); var step = cmd.Backwards ? -1 : 1; var pos3d = Quaternion.Euler (0f, 90f * (int) unit.Direction, 0f) * Vector3.forward; var newCellCoords = unit.CellCoords + new Int2 (Mathf.RoundToInt (pos3d.x), Mathf.RoundToInt (pos3d.z)) * step; var (newCell, ok) = _gs.Value.GetCell (newCellCoords); if (ok) { ref var cell = ref _cellPool.Value.Get (newCell); _animatingPool.Value.Add (entity); ref var moving = ref _movingPool.Value.Add (entity); moving.Point = cell.View.Transform.localPosition; unit.CellCoords = newCellCoords; } } } } }
Компонент Animating - это маркер, говорящий о том, что юнит сейчас занят, и никто не может принимать команды.
namespace Client { struct Animating { } }
namespace Client { struct Moving { public Vector3 Point; } }
Теперь система твининга между клетками:
namespace Client { sealed class UnitMoveSystem : IEcsRunSystem { readonly EcsFilterInject<Inc<Unit, Moving>> _movingUnits = default; readonly EcsPoolInject<Animating> _animatedPool = default; readonly EcsCustomInject<TimeService> _ts = default; const float DistanceToStop = 0.001f; public void Run (EcsSystems systems) { foreach (var entity in _movingUnits.Value) { ref var unit = ref _movingUnits.Pools.Inc1.Get (entity); ref var move = ref _movingUnits.Pools.Inc2.Get (entity); unit.Position = Vector3.Lerp (unit.Position, move.Point, unit.MoveSpeed * _ts.Value.DeltaTime); if ((unit.Position - move.Point).sqrMagnitude <= DistanceToStop) { unit.Position = move.Point; _animatedPool.Value.Del (entity); _movingUnits.Pools.Inc2.Del (entity); } unit.Transform.localPosition = unit.Position; } } } }
Теперь система для начала поворотов:
namespace Client { sealed class UnitStartRotatingSystem : IEcsRunSystem { readonly EcsFilterInject<Inc<Unit, RotateCommand>, Exc<Animating>> _units = default; readonly EcsPoolInject<Animating> _animatingPool = default; readonly EcsPoolInject<Rotating> _rotatingPool = default; public void Run (EcsSystems systems) { foreach (var entity in _units.Value) { ref var unit = ref _units.Pools.Inc1.Get (entity); ref var rot = ref _units.Pools.Inc2.Get (entity); var newDir = (int) unit.Direction + rot.Side; if (newDir == -1) { newDir += 4; } newDir %= 4; unit.Direction = (Direction) newDir; var actualDir = Quaternion.Euler (0f, 90f * (int) unit.Direction, 0f); ref var rotating = ref _rotatingPool.Value.Add (entity); _animatingPool.Value.Add (entity); rotating.Target = actualDir; } } } }
И теперь система поворотов:
namespace Client { sealed class UnitRotateSystem : IEcsRunSystem { readonly EcsFilterInject<Inc<Unit, Rotating>> _rotatingUnits = default; readonly EcsPoolInject<Animating> _animatedPool = default; readonly EcsCustomInject<TimeService> _ts = default; const float DiffToStop = 0.001f; public void Run (EcsSystems systems) { foreach (var entity in _rotatingUnits.Value) { ref var unit = ref _rotatingUnits.Pools.Inc1.Get (entity); ref var rotate = ref _rotatingUnits.Pools.Inc2.Get (entity); unit.Rotation = Quaternion.Lerp (unit.Rotation, rotate.Target, _ts.Value.DeltaTime * unit.RotateSpeed); if ((unit.Rotation.eulerAngles - rotate.Target.eulerAngles).sqrMagnitude <= DiffToStop) { unit.Rotation = rotate.Target; _animatedPool.Value.Del (entity); _rotatingUnits.Pools.Inc2.Del (entity); } unit.Transform.localRotation = unit.Rotation; } } } }
И не забудьте добавить все системы в стартап.
Отлично, теперь наш герой движется и поворачивается!

В следующей части мы разберем более сложные механики и перейдем к разработке поведения врагов.
Туториал подготовлен в соавторстве с Владимиром Роттердамским
