Создание dungeon crawler’а с LeoECS Lite. Часть 1

от автора

Друзья, это начало нового цикла статей про создание игры жанра 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 {     [ExecuteAlways] // Код ниже должен исполняться всегда.     [SelectionBase] // Если вы кликнете на внутреннюю запчасть префаба, то выделится именно этот объект     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;          [ContextMenu ("Find Cells")] #if UNITY_EDITOR         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 рекомендуется использовать отдельный мир для короткоживущих энтити-ивентов, так как для каждого мира пулы имеют размер [количество сущностей; количество сущностей * 2] включительно (в зависимости от того, когда был изменен их размер). То есть, если у вас в мире 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<Animated>> _units = default;          readonly EcsPoolInject<Animated> _animatedPool = 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);                     _animatedPool.Value.Add (entity);                     ref var moving = ref _movingPool.Value.Add (entity);                     moving.Point = cell.View.Transform.localPosition;                     unit.CellCoords = newCellCoords;                 }             }         }     } }

Компонент Animated — это маркер, говорящий о том, что юнит сейчас занят, и никто не может принимать команды.

namespace Client {     struct Animated { } }
namespace Client {     struct Moving {         public Vector3 Point;     } }

Теперь система твининга между клетками:

namespace Client {     sealed class UnitMoveSystem : IEcsRunSystem {         readonly EcsFilterInject<Inc<Unit, Moving>> _movingUnits = default;          readonly EcsPoolInject<Animated> _animatedPool = default;          readonly EcsCustomInject<TimeService> _ts = default;          const float DistanceToStop = 0.1f;          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);                 unit.Transform.localPosition = unit.Position;                  if ((unit.Position - move.Point).sqrMagnitude <= DistanceToStop) {                     _animatedPool.Value.Del (entity);                     _movingUnits.Pools.Inc2.Del (entity);                 }             }         }     } }

Теперь система для начала поворотов:

namespace Client {     sealed class UnitStartRotatingSystem : IEcsRunSystem {         readonly EcsFilterInject<Inc<Unit, RotateCommand>, Exc<Animated>> _units = default;                  readonly EcsPoolInject<Animated> _animatedPool = 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);                 _animatedPool.Value.Add (entity);                 rotating.Target = actualDir;             }         }     } }

И теперь система поворотов:

namespace Client {     sealed class UnitRotateSystem : IEcsRunSystem {         readonly EcsFilterInject<Inc<Unit, Rotating>> _rotatingUnits = default;          readonly EcsPoolInject<Animated> _animatedPool = default;          readonly EcsCustomInject<TimeService> _ts = default;          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);                 unit.Transform.localRotation = unit.Rotation;                  if ((unit.Rotation.eulerAngles - rotate.Target.eulerAngles).sqrMagnitude <= 0.1f) {                     _animatedPool.Value.Del (entity);                     _rotatingUnits.Pools.Inc2.Del (entity);                 }             }         }     } }

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

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

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


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


Комментарии

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

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