Building a dungeon crawler with LeoECS Lite. Part 2

Friends, in this part we will create enemies, implement turn-by-turn system, ability mechanics and write simple AI for enemy units.

Before reading this part, please read previous.

But let’s start by splitting the MonoBehaviour class cellview for two: SnapTransform and cellview. This is necessary to separate the magnetization of an object to a cell on the grid and its selection in the editor, that is, in fact, to distinguish between two different behaviors.

namespace Client {
#if UNITY_EDITOR
    [ExecuteAlways]
    [SelectionBase]
#endif
    sealed class SnapTransform : MonoBehaviour {
        public Transform Transform;
        public float XzStep = 3f;
        public float YStep = 1f;
        
#if UNITY_EDITOR
        void Awake () {
            Transform = transform;
        }

        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;
            }
        }
#endif
    }
}
namespace Client {
#if UNITY_EDITOR
    [ExecuteAlways]
    [SelectionBase]
#endif
    sealed class CellView : MonoBehaviour {
        public Transform Transform;
        public float Size = 3f;

#if UNITY_EDITOR
        void Awake () {
            Transform = transform;
        }
        
        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 * Size / 2 - Vector3.forward * Size / 2 + Vector3.up * yAdd;
            var leftUp = curPos - Vector3.right * Size / 2 + Vector3.forward * Size / 2 + Vector3.up * yAdd;
            var rightDown = curPos + Vector3.right * Size / 2 - Vector3.forward * Size / 2 + Vector3.up * yAdd;
            var rightUp = curPos + Vector3.right * Size / 2 + Vector3.forward * Size / 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
    }
}

Now we can create spawn markers for enemies, add a component to them SnapTransform and it is convenient to put them on the grid without having to select them as a cell. (cells themselves will have both components)

namespace Client {
    sealed class SpawnMarker : MonoBehaviour {
        public Transform Transform;
        public string PrefabName; // имя префаба
        public Side Side;

        public void Awake () {
            Transform = transform;
        }
    }
}

As you can see, there is a field like side. This is a simple enum that represents the side of the unit (user/AI).

namespace Client {
    public enum Side {
        User = 0,
        Enemy = 1,
    }
}

As you remember, in the last part we implemented a context menu in the SceneData class to search for cells on the map. Let’s do the same for the spawn markers.

namespace Client {
    sealed class SceneData : MonoBehaviour {
        public CellView[] Cells;
        public SpawnMarker[] Markers;
        
#if UNITY_EDITOR
        [ContextMenu ("Find Cells")]
        void FindCells () {
            Cells = FindObjectsOfType<CellView> ();
            Debug.Log ($"Successfully found {Cells.Length} cells!");
        }

        [ContextMenu ("Find Markers")]
        void FindMarkers () {
            Markers = FindObjectsOfType<SpawnMarker> ();
            Debug.Log ($"Successfully found {Markers.Length} markers!");
        }
#endif
    }
}

We will also add the cell size and the InBounds method to GridServicewe will need this when initializing and processing the behavior of enemies.

namespace Client {
    sealed class GridService {
        public int CellSize = 3;

        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) {
            if (!InBounds (coords)) {
                return (-1, false);
            }
            
            var entity = _cells[_width * coords.Y + coords.X] - 1;
            return (entity, entity >= 0);
        }

        bool InBounds (Int2 coords) {
            return coords.X >= 0 && coords.Y >= 0;
        }

        public void AddCell (Int2 coords, int entity) {
            _cells[_width * coords.Y + coords.X] = entity + 1;
        }
    }
}
	

We will also add an entity field to the cell component – this will be needed to determine which cells are occupied by whom or not occupied at all.

namespace Client {
    struct Cell {
        public CellView View;
        public int Entity;
    }
}

Let’s update the component unit. You will need to add data such as the number of action points (hereinafter referred to as AP), “initiative” (this will be needed when choosing the next unit during a turn change), health, range (will be needed for AI) and side.

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;
        public int ActionPoints;
        public int Initiative;
        public int Health;
        public int Radius;
        public Side Side;
    }
}

Now let’s deal with the mechanics of abilities.

We will store the ability configuration in the format scriptable object in Unity. Each ability will have its own name, action point cost, cast distance, and damage.

namespace Client {
    [CreateAssetMenu]
    public class AbilityConfig : ScriptableObject {
        public string Name;
        public int ActionPointsCost;
        public int Damage;
        public int Distance;
    }
}

It will also be convenient to get the ability config through some numeric identifier. Let’s create an array of Scriptable Objects in the main config Configuration.

namespace Client {
    [CreateAssetMenu]
    sealed class Configuration : ScriptableObject {
        public int GridWidth;
        public int GridHeight;
        public LayerMask UnitLayerMask;
        public AbilityConfig[] AbilitiesConfigs;
    }
}

Each unit will have a component HasAbilities with a list of entities for abilities. As you already understood, each ability will be a separate entity with a component Abilityin which we will store the cost in OD, the essence of the owner, the identifier, the distance and the damage.

namespace Client {
    struct HasAbilities : IEcsAutoReset<HasAbilities> {
        public List<int> Entities;

        public void AutoReset (ref HasAbilities c) {
            c.Entities ??= new List<int> (64);
        }
    }
}
namespace Client {
    struct Ability {
        public int ActionPointsCost;
        public int OwnerEntity;
        public int Id;
        public int Damage;
        public int Distance;
    }
}

When it is necessary to apply the ability, we will add a component to its essence Applied and save inside the target entity.

namespace Client {
    struct Applied {
        public int Target;
    }
}

Also, each ability will have its own unique component, which, in fact, denotes it. Let’s say the “weak attack” ability will have a component Light attackthe ability of “strong” – HeavyAttack. This will be necessary so that each ability has its own trigger logic.

Let’s create a separate service AbilityHelper, where we will store a dictionary with a number as a key and a callback as a value. In fact, we will receive a link to the method for adding a component by the ability identifier, that is, we will compare the number with the type.

namespace Client {
    sealed class AbilityHelper {
        readonly Dictionary<int, Action<EcsWorld, int>> _addComponentCallbacks;

        public AbilityHelper () {
            _addComponentCallbacks = new Dictionary<int, Action<EcsWorld, int>> {
                { 0, AddComponent<LightAttack> },
                { 1, AddComponent<HeavyAttack> },
              	{ 2, AddComponent<PowerShot> }
            };
        }

        void AddComponent<T> (EcsWorld world, int entity) where T : struct {
            world.GetPool<T> ().Add (entity);
        }

        public Action<EcsWorld, int> GetAddComponentCallback (int abilityIdx) {
            return _addComponentCallbacks.TryGetValue (abilityIdx, out Action<EcsWorld, int> cb) ? cb : null;
        }
    }
}

Don’t forget to create an instance of this service in the startup and inject a link to it into the systems.

I created three SO abilities: LightAttack, PowerShot and HeavyAttack and added them to the list of abilities in the main config.

There will be buttons on the screen for abilities that we instantiate at the start of the game. The button should store the ability index in an array stored in the HasAbilities component of the player unit. Let’s create a separate monobeh Ability Viewin which we will also save links to TMP of the name of the ability and the number corresponding to the key on the keyboard to activate the ability.

namespace Client {
    sealed class AbilityView : MonoBehaviour {
        // Ability index for owner.
        public int AbilityIdx;
        public TextMeshProUGUI Name;
        public TextMeshProUGUI KeyIdx;
    }
}

We will also create a service that will store the active side and the active unit.

namespace Client {
    sealed class RoundService {
        public Side ActiveSide;
        public readonly int StateMax = 2;

        public int ActiveUnit;

        public RoundService (Side activeSide) {
            ActiveSide = activeSide;
        }
    }
}

We move on. Let’s update the player initialization system and add ability creation.

namespace Client {
    sealed class PlayerInitSystem : IEcsInitSystem {
        readonly EcsPoolInject<Unit> _unitPool = default;
        readonly EcsPoolInject<ControlledByPlayer> _controlledByPlayerPool = default;
        readonly EcsPoolInject<HasAbilities> _hasAbilitiesPool = default;
        readonly EcsPoolInject<Ability> _abilityPool = default;

        readonly EcsCustomInject<RoundService> _rs = default;
        readonly EcsCustomInject<Configuration> _config = default;
        readonly EcsCustomInject<AbilityHelper> _ah = default;

        [EcsUguiNamed (Idents.Ui.Abilities)]
        readonly Transform _abilitiesLayoutGroup = default;
        [EcsUguiNamed (Idents.Ui.GameOverPopup)]
        readonly GameObject _popup = default;
        
        public void Init (EcsSystems systems) {
            var world = _unitPool.Value.GetWorld ();
            var playerEntity = world.NewEntity ();

            ref var unit = ref _unitPool.Value.Add (playerEntity);
            _controlledByPlayerPool.Value.Add (playerEntity);
            
            var playerPrefab = Resources.Load<UnitView> ("Player");
            var playerView =  Object.Instantiate (playerPrefab, Vector3.zero, Quaternion.identity);

            _rs.Value.ActiveUnit = playerEntity;
            
            unit.Direction = 0;
            unit.CellCoords = new Int2 (0, 0);
            unit.Transform = playerView.transform;
            unit.Position = Vector3.zero;
            unit.Rotation = Quaternion.identity;
            unit.MoveSpeed = 3f;
            unit.RotateSpeed = 10f;
            unit.ActionPoints = 2;
            unit.Health = 10;
            unit.Initiative = Random.Range (1, 10);
            unit.Side = Side.User;
            unit.View = playerView;

            CreateAbilities (playerEntity, world);
            _popup.SetActive (false);
        }

        void CreateAbilities (int playerEntity, EcsWorld world) {
            var abilityAsset = Resources.Load<AbilityView> ("Ability");
            ref var hasAbilities = ref _hasAbilitiesPool.Value.Add (playerEntity);
            
            for (int i = 0; i < 3; i++) {
                var abilityConfig = _config.Value.AbilitiesConfigs[i];
                var abilityEntity = world.NewEntity ();
                
                ref var ability = ref _abilityPool.Value.Add (abilityEntity);
                ability.ActionPointsCost = abilityConfig.ActionPointsCost;
                ability.OwnerEntity = playerEntity;
                ability.Id = i;
                ability.Damage = abilityConfig.Damage;
                ability.Distance = abilityConfig.Distance;

                var abilityView = Object.Instantiate (abilityAsset, _abilitiesLayoutGroup);
                abilityView.Name.text = $"{abilityConfig.Name}\nCost: {abilityConfig.ActionPointsCost}";
                abilityView.AbilityIdx = i;
                abilityView.KeyIdx.text = (i + 1).ToString();
                _ah.Value.GetAddComponentCallback (i)?.Invoke (world, abilityEntity);
                
                hasAbilities.Entities.Add (abilityEntity);
            }
        }
    }
}

As you can see, I also created a widget with a Horizontal Layout Group component to align the buttons and placed it on the bottom of the canvas. To access it on systems, we can use the uGui extension and attach NoAction to the widget, specifying the “Awake” name registration type.

Now let’s create the enemy initialization system.

namespace Client {
    sealed class AiInitSystem : IEcsInitSystem {
        readonly EcsCustomInject<SceneData> _sceneData = default;
        readonly EcsCustomInject<GridService> _gs = default;
        readonly EcsCustomInject<Configuration> _config = default;
        readonly EcsCustomInject<AbilityHelper> _ah = default;

        readonly EcsPoolInject<HasAbilities> _hasAbilitiesPool = default;
        readonly EcsPoolInject<Ability> _abilityPool = default;

        readonly EcsPoolInject<Cell> _cellPool = default;
        readonly EcsPoolInject<Unit> _unitPool = default;

        public void Init (EcsSystems systems) {
            for (var i = 0; i < _sceneData.Value.Markers.Length; i++) {
                var marker = _sceneData.Value.Markers[i];
                var asset = Resources.Load<UnitView> (Idents.Paths.Units + marker.PrefabName);
                var position = marker.Transform.position;
                var view = Object.Instantiate (asset, position, Quaternion.identity);

                var coords = new Int2 {
                    X = (int) (position.x / _gs.Value.CellSize),
                    Y = (int) (position.z / _gs.Value.CellSize)
                };

                var unitEntity = _unitPool.Value.GetWorld ().NewEntity ();
                ref var unit = ref _unitPool.Value.Add (unitEntity);
                unit.Direction = 0;
                unit.CellCoords = coords;
                var transform = view.transform;
                unit.Transform = transform;
                unit.Position = transform.position;
                unit.Rotation = Quaternion.identity;
                unit.MoveSpeed = 3f;
                unit.RotateSpeed = 10f;
                unit.ActionPoints = 2;
                unit.Initiative = Random.Range (1, 10);
                unit.Health = 3;
                unit.Radius = 2;
                unit.Side = marker.Side;
                unit.View = view;
                
                var (cellEntity, ok) = _gs.Value.GetCell (coords);

                if (ok) {
                    ref var cell = ref _cellPool.Value.Get (cellEntity);

                    cell.Entity = unitEntity;
                }

                CreateAbilities (unitEntity, _unitPool.Value.GetWorld ());
            }
        }
        
        void CreateAbilities (int entity, EcsWorld world) {
            ref var hasAbilities = ref _hasAbilitiesPool.Value.Add (entity);

            for (int i = 0; i < 3; i++) {
                var abilityConfig = _config.Value.AbilitiesConfigs[i];
                var abilityEntity = world.NewEntity ();
                
                ref var ability = ref _abilityPool.Value.Add (abilityEntity);
                ability.ActionPointsCost = abilityConfig.ActionPointsCost;
                ability.OwnerEntity = entity;
                ability.Id = i;
                ability.Damage = abilityConfig.Damage;
                ability.Distance = abilityConfig.Distance;

                _ah.Value.GetAddComponentCallback (i)?.Invoke (world, abilityEntity);
                
                hasAbilities.Entities.Add (abilityEntity);
            }
        }
    }
}

Let’s do a turnaround.

As you remember, we will store in the service Round Service the essence of the active unit (i.e. the one who is currently walking) and its side (Side). The turn ends either when the active unit runs out of AP, or it misses a turn (let’s say the player wanted it that way or the AI ​​did not do anything, since the player is far away). When a unit ends its turn, a component will be attached to its entity TurnFinished, and when all units of one side end their turn, the previously mentioned flag is removed from them and the “active” side changes. All this will be needed in order to correctly find a new active unit.

Let’s make a change to the unit’s movement system:


namespace Client {
    sealed class UnitMoveSystem : IEcsRunSystem {
        readonly EcsFilterInject<Inc<Unit, Moving>> _movingUnits = default;

        readonly EcsPoolInject<Animating> _animatingPool = default;
        readonly EcsPoolInject<TurnFinished> _turnFinishedPool = default;
        readonly EcsPoolInject<NextUnitEvent> _nextUnitEventPool = Idents.Worlds.Events;

        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;
                    _animatingPool.Value.Del (entity);
                    _movingUnits.Pools.Inc2.Del (entity);
                    if (unit.ActionPoints == 0) {
                        unit.ActionPoints = 2;
                        _turnFinishedPool.Value.Add (entity);
                        _nextUnitEventPool.Value.Add (_nextUnitEventPool.Value.GetWorld ().NewEntity ());
                    }
                }
                
                unit.Transform.localPosition = unit.Position;
            }
        }
    }
}

And to the start-up system:

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;
        readonly EcsCustomInject<RoundService> _rs = default;

        public void Run (EcsSystems systems) {
            foreach (var entity in _units.Value) {
                ref var unit = ref _units.Pools.Inc1.Get (entity);

                if (unit.Side != _rs.Value.ActiveSide) {
                    continue;
                }
                
                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);
                    
                    if (cell.Entity != -1) {
                        continue;
                    }

                    var (curCellEntity, _) = _gs.Value.GetCell (unit.CellCoords);
                    ref var curCell = ref _cellPool.Value.Get (curCellEntity);
                    curCell.Entity = -1;
                    
                    cell.Entity = entity;
                    _animatingPool.Value.Add (entity);
                    ref var moving = ref _movingPool.Value.Add (entity);
                    moving.Point = cell.View.Transform.localPosition;
                    unit.CellCoords = newCellCoords;

                    unit.ActionPoints--;
                }
            }
        }
    }
}

Component NextTurnEvent – a common event that is needed to change the active unit and, possibly, the side too.

Let’s create a move change system:

namespace Client {
    sealed class NextUnitTurnSystem : IEcsRunSystem {
        readonly EcsFilterInject<Inc<NextUnitEvent>> _nextUnitEvents = Idents.Worlds.Events;
        readonly EcsFilterInject<Inc<Unit>, Exc<TurnFinished>> _activeUnits = default;
        readonly EcsFilterInject<Inc<TurnFinished>> _finishedUnits = default;

        readonly EcsCustomInject<RoundService> _rs = default;

        public void Run (EcsSystems systems) {
            foreach (var entity in _nextUnitEvents.Value) {
                var (newEntity, ok) = FindNewUnit ();
                
                if (ok) {
                    // successfully found.
                    _rs.Value.ActiveUnit = newEntity;
                } else {
                    // reset "Finished" flag because we change active side
                    var found = false;
                    ClearFinishedFlag ();
                    while (!found) {
                        var newSide = ((int) _rs.Value.ActiveSide + 1) % _rs.Value.StateMax;
                        _rs.Value.ActiveSide = (Side) newSide;
                        var (anotherNewEntity, foundNewUnit) = FindNewUnit ();
                        found = foundNewUnit;
                        _rs.Value.ActiveUnit = anotherNewEntity;
                    }
                }
            }
        }

        void ClearFinishedFlag () {
            foreach (var entity in _finishedUnits.Value) {
                _finishedUnits.Pools.Inc1.Del (entity);
            }
        }

        (int, bool) FindNewUnit () {
            var found = -1;
            var min = int.MaxValue;
            foreach (var entity in _activeUnits.Value) {
                ref var unit = ref _activeUnits.Pools.Inc1.Get (entity);

                if (unit.Initiative < min && unit.Side == _rs.Value.ActiveSide) {
                    found = entity;
                }
            }

            return (found, found >= 0);
        }
    }
}

As you can see, if all units of one side have completed their turns and have TurnFinished tags, then we remove this tag and change sides. Within the party we are looking for the unit that will have the smallest initiative.

Now it’s time to write artificial intelligence. It will work relatively simply: the enemy unit will have to try to use some kind of ability, and if it fails to use any, then it will close the distance with the player. But how to determine if he can use a particular ability? Do not take the same random?

To do this, we must come up with algorithms for checking whether this or that ability is suitable in the situation in which the unit is now. We will add methods to the AbilityHelper service for each ability, in which we will write the validation check logic. By analogy with methods that add a component, we will receive the validator method through the ID of the ability. Of course, mesh and unit data will be needed, so the service update will be a major one. Basically, we are implementing Utility AI.

We will also use ability validation for the player. At the moment when he presses the button to cast the ability, it will be necessary to check with the validator whether he can do it.

namespace Client {
    sealed class AbilityHelper {
        internal delegate int ValidationCheck (in Unit unit, int damage);

        readonly Dictionary<int, Action<EcsWorld, int>> _addComponentCallbacks;
        readonly Dictionary<int, ValidationCheck> _validationCallbacks;

        readonly GridService _gs;

        readonly EcsPool<Cell> _cellPool;

        public AbilityHelper (GridService gs, EcsPool<Cell> cellPool) {
            _addComponentCallbacks = new Dictionary<int, Action<EcsWorld, int>> {
                { 0, AddComponent<LightAttack> },
                { 1, AddComponent<HeavyAttack> },
                { 2, AddComponent<PowerShot>}
            };

            _validationCallbacks = new Dictionary<int, ValidationCheck> {
                { 0, LightAttackValidate },
                { 1, HeavyAttackValidate },
                { 2, PowerShotValidate }
            };

            _gs = gs;
            _cellPool = cellPool;
        }

        void AddComponent<T> (EcsWorld world, int entity) where T : struct {
            world.GetPool<T> ().Add (entity);
        }

        public Action<EcsWorld, int> GetAddComponentCallback (int abilityIdx) {
            return _addComponentCallbacks.TryGetValue (abilityIdx, out Action<EcsWorld, int> cb) ? cb : null;
        }

        public ValidationCheck GetValidateCallback (int abilityIdx) {
            return _validationCallbacks.TryGetValue (abilityIdx, out ValidationCheck cb) ? cb : null;
        }

        int LightAttackValidate (in Unit unit, int damage) {
            var coords = unit.CellCoords;
            var pos3d = Quaternion.Euler (0f, 90f * (int) unit.Direction, 0f) * Vector3.forward;
            var add = new Int2 (Mathf.RoundToInt (pos3d.x), Mathf.RoundToInt (pos3d.z));
            var frontCell = coords + add;
            var (cellEntity, ok) = _gs.GetCell (frontCell);

            if (ok) {
                ref var cell = ref _cellPool.Get (cellEntity);
                if (cell.Entity != -1) {
                    return damage;
                }
            }

            return 0;
        }

        int HeavyAttackValidate (in Unit unit, int damage) {
            var coords = unit.CellCoords;
            var pos3d = Quaternion.Euler (0f, 90f * (int) unit.Direction, 0f) * Vector3.forward;
            var add = new Int2 (Mathf.RoundToInt (pos3d.x), Mathf.RoundToInt (pos3d.z));

            var frontCell = coords + add;
            var (cellEntity, ok) = _gs.GetCell (frontCell);

            if (ok) {
                ref var cell = ref _cellPool.Get (cellEntity);
                if (cell.Entity != -1) {
                    return damage;
                }
            }

            return 0;
        }

        int PowerShotValidate (in Unit unit, int damage) {
            var coords = unit.CellCoords;
            var pos3d = Quaternion.Euler (0f, 90f * (int) unit.Direction, 0f) * Vector3.forward;
            var add = new Int2 (Mathf.RoundToInt (pos3d.x), Mathf.RoundToInt (pos3d.z));
            
            var frontCell = coords + add;
            var (cellEntity, ok) = _gs.GetCell (frontCell);
            if (ok) {
                ref var cell = ref _cellPool.Get (cellEntity);
                if (cell.Entity != -1) {
                    return 0;
                }
            }
            
            frontCell = coords + add * 2;
            var (anotherCellEntity, ok2) = _gs.GetCell (frontCell);
            if (ok2) {
                ref var cell = ref _cellPool.Get (anotherCellEntity);
                if (cell.Entity != -1) {
                    return damage;
                }
            }

            return 0;
        }
    }
}

Before we start writing AI logic, let’s also create an event component apply for abilities.

namespace Client {
    struct Apply { }
}
namespace Client {
    sealed class ApplyAbilitySystem : IEcsRunSystem {
        readonly EcsFilterInject<Inc<Ability, Apply>> _abilities = default;

        readonly EcsPoolInject<Unit> _unitPool = default;
        readonly EcsPoolInject<Cell> _cellPool = default;
        readonly EcsPoolInject<Applied> _appliedPool = default;
        readonly EcsPoolInject<TurnFinished> _turnFinishedPool = default;
        readonly EcsPoolInject<NextUnitEvent> _nextUnitEventPool = Idents.Worlds.Events;

        readonly EcsCustomInject<GridService> _gs = default;
        readonly EcsCustomInject<RoundService> _rs = default;

        public void Run (EcsSystems systems) {
            foreach (var entity in _abilities.Value) {
                ref var ability = ref _abilities.Pools.Inc1.Get (entity);
                ref var unit = ref _unitPool.Value.Get (ability.OwnerEntity);

                if (unit.Side != _rs.Value.ActiveSide) {
                    continue;
                }
                
                var pos3d = Quaternion.Euler (0f, 90f * (int) unit.Direction, 0f) * Vector3.forward;
                var add = new Int2 (Mathf.RoundToInt (pos3d.x), Mathf.RoundToInt (pos3d.z)) * ability.Distance;

                var cellCoords = unit.CellCoords + add;
                var (cellEntity, ok) = _gs.Value.GetCell (cellCoords);

                if (ok) {
                    ref var cell = ref _cellPool.Value.Get (cellEntity);
                    if (unit.ActionPoints < ability.ActionPointsCost) {
                    }
                    if (cell.Entity != -1 && unit.ActionPoints >= ability.ActionPointsCost) {
                        ref var applied = ref _appliedPool.Value.Add (entity);
                        applied.Target = cell.Entity;
                        unit.ActionPoints -= ability.ActionPointsCost;

                        if (unit.ActionPoints == 0) {
                            unit.ActionPoints = 2;
                            _turnFinishedPool.Value.Add (ability.OwnerEntity);
                            _nextUnitEventPool.Value.Add (_nextUnitEventPool.Value.GetWorld ().NewEntity ());
                        }
                    }
                }
            }
        }
    }
}

We will also give the player the opportunity to use abilities. You remember that we created buttons with the ability index in the owner’s (player’s) ability cache. Let’s implement the mechanics of using abilities through button presses. Let’s change our system a bit UserButtonsInputSystemadding a new method and some pools there:

[Preserve]
[EcsUguiClickEvent (Idents.Ui.Ability, Idents.Worlds.Events)]
void OnClickAbility (in EcsUguiClickEvent e) {
    var abilityView = e.Sender.GetComponent<AbilityView> ();
    foreach (var entity in _units.Value) {
        ref var abilities = ref _hasAbilitiesPool.Value.Get (entity);
        var abilityEntity = abilities.Entities[abilityView.AbilityIdx];
        ref var ability = ref _abilityPool.Value.Get (abilityEntity);
        ref var unit = ref _units.Pools.Inc1.Get (entity);
        var dmg = _ah.Value.GetValidateCallback (ability.Id).Invoke (unit, ability.Damage);
        if (dmg != 0 && unit.ActionPoints >= ability.ActionPointsCost) {
            _applyPool.Value.Add (abilityEntity);
        }
    }
}

Also a little input system from the keyboard UserKeyboardInputSystem:

namespace Client {
    sealed class UserKeyboardInputSystem : IEcsRunSystem {
        readonly EcsFilterInject<Inc<Unit, ControlledByPlayer>> _units = default;

        readonly EcsPoolInject<MoveCommand> _moveCommandPool = default;
        readonly EcsPoolInject<RotateCommand> _rotateCommandPool = default;
        readonly EcsPoolInject<HasAbilities> _hasAbilitiesPool = default;
        readonly EcsPoolInject<Apply> _applyPool = default;
        readonly EcsPoolInject<TurnFinished> _turnFinishedPool = default;
        readonly EcsPoolInject<NextUnitEvent> _nextUnitEventPool = Idents.Worlds.Events;
        readonly EcsPoolInject<Ability> _abilityPool = default;

        readonly EcsCustomInject<AbilityHelper> _ah = default;

        public void Run (EcsSystems systems) {
            foreach (var entity in _units.Value) {
                ref var unit = ref _units.Pools.Inc1.Get (entity);
                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;
                }

                if (Input.GetKeyDown (KeyCode.Alpha1)) {
                    TryApplyAbility (0, entity, unit);
                }
                
                if (Input.GetKeyDown (KeyCode.Alpha2)) {
                    TryApplyAbility (1, entity, unit);
                }

                if (Input.GetKeyDown (KeyCode.Alpha3)) {
                    TryApplyAbility (2, entity, unit);
                }

                if (Input.GetKeyDown (KeyCode.Space) && !_turnFinishedPool.Value.Has (entity)) {
                    unit.ActionPoints = 2;
                    _turnFinishedPool.Value.Add (entity);
                    _nextUnitEventPool.Value.Add (_nextUnitEventPool.Value.GetWorld ().NewEntity ());
                }
            }
        }

        void TryApplyAbility (int abilityIdx, int entity, in Unit unit) {
            ref var abilities = ref _hasAbilitiesPool.Value.Get (entity);

            var abilityEntity = abilities.Entities[abilityIdx];
            ref var ability = ref _abilityPool.Value.Get (abilityEntity);
            
            var dmg = _ah.Value.GetValidateCallback (ability.Id).Invoke (unit, ability.Damage);
            if (dmg != 0 && unit.ActionPoints >= ability.ActionPointsCost) {
                _applyPool.Value.Add (abilityEntity);
            }
        }
    }
}

(you can also add methods for other keys in the same way)

Now let’s move on to the main part of the AI ​​and create a router system that will send commands to the active unit if it is on the side of the enemies.

namespace Client {
    sealed class AiCommandsSystem : IEcsRunSystem {
        readonly EcsPoolInject<Unit> _unitPool = default;
        readonly EcsPoolInject<Cell> _cellPool = default;

        readonly EcsPoolInject<TurnFinished> _turnFinishedPool = default;
        readonly EcsPoolInject<NextUnitEvent> _nextUnitEventPool = Idents.Worlds.Events;
        readonly EcsPoolInject<Animating> _animatingPool = default;
        readonly EcsPoolInject<RotateCommand> _rotateCommandPool = default;
        readonly EcsPoolInject<MoveCommand> _moveCommandPool = default;
        readonly EcsPoolInject<HasAbilities> _hasAbilitiesPool = default;
        readonly EcsPoolInject<Apply> _applyPool = default;
        readonly EcsPoolInject<Ability> _abilityPool = default;

        readonly EcsCustomInject<RoundService> _rs = default;
        readonly EcsCustomInject<GridService> _gs = default;
        readonly EcsCustomInject<AbilityHelper> _ah = default;

        public void Run (EcsSystems systems) {
            if (_animatingPool.Value.Has (_rs.Value.ActiveUnit)) {
                return;
            }

            if (_rs.Value.ActiveSide == Side.Enemy) {
                ref var unit = ref _unitPool.Value.Get (_rs.Value.ActiveUnit);
                var cellCoords = unit.CellCoords;
                var userCellCoords = new Int2 ();
                var userEntity = -1;

                // Checking cells that in radius range.
                for (int i = -unit.Radius; i < unit.Radius + 1; i++) {
                    for (int j = -unit.Radius; j < unit.Radius + 1; j++) {
                        var newCellCoords = new Int2 {
                            X = cellCoords.X + i,
                            Y = cellCoords.Y + j
                        };
                        var (cellToCheck, ok) = _gs.Value.GetCell (newCellCoords);

                        if (ok) {
                            ref var cell = ref _cellPool.Value.Get (cellToCheck);
                            if (cell.Entity != -1) {
                                ref var unitOnCell = ref _unitPool.Value.Get (cell.Entity);

                                if (unitOnCell.Side != Side.Enemy) {
                                    userEntity = cell.Entity;
                                    userCellCoords = newCellCoords;
                                    break;
                                }
                            }
                        }
                    }
                }

                // if user is detected, attack or chase him. if user is not detected, finish turn.
                if (userEntity != -1) {
                    var (ability, ok) = CheckAbilitiesValidation (_rs.Value.ActiveUnit);
                    if (ok) {
                        _applyPool.Value.Add (ability);
                    } else {
                        ChasePlayer (cellCoords, userCellCoords, unit);
                    }
                } else {
                    _turnFinishedPool.Value.Add (_rs.Value.ActiveUnit);
                    _nextUnitEventPool.Value.Add (_nextUnitEventPool.Value.GetWorld ().NewEntity ());
                }
            }
        }

        (int, bool) CheckAbilitiesValidation (int activeUnit) {
            ref var hasAbilities = ref _hasAbilitiesPool.Value.Get (activeUnit);
            ref var unit = ref _unitPool.Value.Get (activeUnit);
            var maxDamage = 0;
            var foundAbility = -1;

            for (int i = 0; i < hasAbilities.Entities.Count; i++) {
                ref var ability = ref _abilityPool.Value.Get (hasAbilities.Entities[i]);
                // skip if not enough AP.
                if (unit.ActionPoints < ability.ActionPointsCost) {
                    continue;
                }
                var dmg = _ah.Value.GetValidateCallback (ability.Id).Invoke (unit, ability.Damage);
                if (dmg > maxDamage) {
                    foundAbility = hasAbilities.Entities[i];
                    maxDamage = dmg;
                }
            }

            return (foundAbility, foundAbility != -1);
        }

        void ChasePlayer (Int2 activeUnitCoords, Int2 userCoords, in Unit activeUnit) {
            if (userCoords.X != activeUnitCoords.X) {
                // X coord diff
                var diff = Mathf.Clamp (userCoords.X - activeUnitCoords.X, -1, 1);
                var newCoords = activeUnitCoords + new Int2 { X = diff, Y = 0 };
                var (cell, ok) = _gs.Value.GetCell (newCoords);
                var direction = (Direction) ((int) Direction.South - diff);
                if (ok) {
                    // if can move along x axis
                    // rotate to this cell and move there.
                    if (activeUnit.Direction != direction) {
                        ref var rotate = ref _rotateCommandPool.Value.Add (_rs.Value.ActiveUnit);
                        rotate.Side = (int) direction - (int) activeUnit.Direction;
                    } else {
                        _moveCommandPool.Value.Add (_rs.Value.ActiveUnit);
                    }
                    return;
                }
            }

            if (userCoords.Y != activeUnitCoords.Y) {
                // Y coord diff
                var diff = Mathf.Clamp (userCoords.Y - activeUnitCoords.Y, -1, 1);
                var newCoords = activeUnitCoords + new Int2 { X = 0, Y = diff };
                var (cell, ok) = _gs.Value.GetCell (newCoords);
                var direction = (Direction) ((int) Direction.East - diff);
                if (ok) {
                    // if can move along y axis
                    // rotate to this cell and move there.
                    if (activeUnit.Direction != direction) {
                        ref var rotate = ref _rotateCommandPool.Value.Add (_rs.Value.ActiveUnit);
                        rotate.Side = (int) direction - (int) activeUnit.Direction;
                    } else {
                        _moveCommandPool.Value.Add (_rs.Value.ActiveUnit);
                    }
                }
            }
        }
    }
}

The general scheme works like this: if there is an enemy (player) in the radius of the unit, then you need to check the ability validators. If any ability deals damage greater than zero, then you need to use it. If not, then you need to reduce the distance with the player (along one of the axes), turning in the direction of movement, and so on until the active unit changes.

Let’s create a unit death event component:

namespace Client {
    struct DeathEvent { }
}

Now let’s create systems for abilities:

namespace Client {
    sealed class LightAttackAbilitySystem : IEcsRunSystem {
        readonly EcsFilterInject<Inc<LightAttack, Applied>> _lightAttacksApplied = default;

        readonly EcsPoolInject<Unit> _unitPool = default;
        readonly EcsPoolInject<Ability> _abilityPool = default;
        readonly EcsPoolInject<DeathEvent> _deathEventPool = default;

        public void Run (EcsSystems systems) {
            foreach (var entity in _lightAttacksApplied.Value) {
                ref var ability = ref _abilityPool.Value.Get (entity);
                ref var applied = ref _lightAttacksApplied.Pools.Inc2.Get (entity);
                ref var unit = ref _unitPool.Value.Get (applied.Target);

                unit.Health -= ability.Damage;

                if (unit.Health <= 0) {
                    _deathEventPool.Value.Add (applied.Target);
                }
            }
        }
    }
}
namespace Client {
    sealed class HeavyAttackAbilitySystem : IEcsRunSystem {
        readonly EcsFilterInject<Inc<HeavyAttack, Applied>> _heavyAttacksApplied = default;

        readonly EcsPoolInject<Unit> _unitPool = default;
        readonly EcsPoolInject<Ability> _abilityPool = default;
        readonly EcsPoolInject<DeathEvent> _deathEventPool = default;

        public void Run (EcsSystems systems) {
            foreach (var entity in _heavyAttacksApplied.Value) {
                ref var ability = ref _abilityPool.Value.Get (entity);
                ref var applied = ref _heavyAttacksApplied.Pools.Inc2.Get (entity);
                ref var unit = ref _unitPool.Value.Get (applied.Target);

                unit.Health -= ability.Damage;

                if (unit.Health <= 0) {
                    _deathEventPool.Value.Add (applied.Target);
                }
            }
        }
    }
}

namespace Client {
    sealed class PowerShotAbilitySystem : IEcsRunSystem {
        readonly EcsFilterInject<Inc<PowerShot, Applied>> _powerShotsApplied = default;

        readonly EcsPoolInject<Unit> _unitPool = default;
        readonly EcsPoolInject<Ability> _abilityPool = default;
        readonly EcsPoolInject<DeathEvent> _deathEventPool = default;

        public void Run (EcsSystems systems) {
            foreach (var entity in _powerShotsApplied.Value) {
                ref var ability = ref _abilityPool.Value.Get (entity);
                ref var applied = ref _powerShotsApplied.Pools.Inc2.Get (entity);
                ref var unit = ref _unitPool.Value.Get (applied.Target);

                unit.Health -= ability.Damage;

                if (unit.Health <= 0) {
                    _deathEventPool.Value.Add (applied.Target);
                }
            }
        }
    }
}

Yes, it turned out very similar, but the abilities may have different logic. In addition, the game designer may require different behavior at any given moment, so we’ll leave this repetition.

Let’s also display the player’s AP on the screen. All you need to do is create a TMP on the canvas, place it where you like, inject it through the uGui extension by attaching it to the NoAction widget, enable registration and update in the code every time someone changes the amount of OD by a couple lines:

[EcsUguiNamed (Idents.Ui.PlayerAp)]
readonly TextMeshProUGUI _playerAp = default;
...
if (unit.Side == Side.User) {
    _playerAp.text = unit.ActionPoints.ToString ();
}

It remains only to implement the death of units. To do this, we need a MonoBehaviour UnitView class, which will have an API for switching animations – this way we can interact with the visual without knowing the details of the prefab hierarchy, i.e. it’s kind of an abstraction layer.

namespace Client {
    sealed class UnitDeathSystem : IEcsRunSystem {
        readonly EcsFilterInject<Inc<Unit, DeathEvent>> _deadUnits = default;

        readonly EcsPoolInject<Cell> _cellPool = default;
        readonly EcsPoolInject<Unit> _unitPool = default;

        readonly EcsCustomInject<GridService> _gs = default;
        readonly EcsCustomInject<RoundService> _rs = default;

        [EcsUguiNamed (Idents.Ui.GameOverPopup)]
        readonly GameObject _popup = default;

        public void Run (EcsSystems systems) {
            foreach (var entity in _deadUnits.Value) {
                ref var unit = ref _deadUnits.Pools.Inc1.Get (entity);

                switch (unit.Side) {
                    case Side.Enemy:
                        var (cellEntity, ok) = _gs.Value.GetCell (unit.CellCoords);

                        if (ok) {
                            ref var cell = ref _cellPool.Value.Get (cellEntity);
                            cell.Entity = -1;
                        }

                        unit.View.DieAnim ();
                        break;
                    case Side.User:
                        var (cellEntity2, ok2) = _gs.Value.GetCell (unit.CellCoords);

                        if (ok2) {
                            ref var cell = ref _cellPool.Value.Get (cellEntity2);
                            cell.Entity = -1;
                        }
                        
                        _popup.SetActive (true);
                        break;
                }

                _unitPool.Value.GetWorld ().DelEntity (entity);
            }
        }
    }
}
namespace Client {
    sealed class UnitView : MonoBehaviour {
        public Animator Animator;

        public void DieAnim () {
            Animator.SetTrigger ("Dead");
        }
    }
}

Don’t forget to attach this monobech to the unit prefab and keep a link to it in the Unit component in the player and AI init systems.

Fight!
Fight!

Great, we have implemented a system of abilities, moves and made interesting enemies that fight with the player using their abilities!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *