Друзья, это начало нового цикла статей про создание игры жанра 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;
            }
        }
    }
}

И не забудьте добавить все системы в стартап.

Отлично, теперь наш герой движется и поворачивается!

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

Комментарии (2)


  1. PaHDoMbI4
    15.04.2022 09:19

    Хороший пост, как всегда.

    С высоты своего бесконечного опыта работы с LeoECS Lite (4 месяца) хочу отметить, что использование EcsCustomInject на этапе разработки усложняет рефакторинг, потому что контролировать что куда заинжектил можно только через комментарий к соответствующей системе, лучше пользоваться внедрением через конструктор.

    И уже отдельно для автора, вот такой класс в эдитор неймспейсе заменит твой CellView и позволит не насиловать юнити атрибутом [ExecuteAlways]

    	[UnityEditor.CustomEditor(typeof(CellView))]
        public class CellViewEditor : UnityEditor.Editor
        {
            [DrawGizmo(GizmoType.Active | GizmoType.Pickable | GizmoType.NonSelected)]
            public static void RenderGizmo(CellView view, GizmoType gizmo)
            {
            		Gizmos.color = ((gizmo & GizmoType.Selected) != 0) ? Color.green : Color.cyan;
     						//...твой скрипт отрисовки
            }
        }


    1. supremestranger Автор
      15.04.2022 21:39

      Спасибо.

      Ну инжект в системы внедряет все данные сразу во все экземпляры систем.

      Насчет CellView, там атрибуты под дефайном и будут работать только в редакторе, так что по идее норм. Вообще, скорее всего стоит разделить компонент на два: один отвечает за снап, другой за отрисовку клеток, так как магнитить к сетке нужно будет, скорее всего, не только клетки.