Друзья, в этой части мы создадим врагов, реализуем поочередную систему ходов, механику способностей и напишем простой ИИ вражеским юнитам.
Перед прочтением этой части ознакомьтесь с предыдущей.
Но давайте начнем с разделения MonoBehaviour класса CellView на два: SnapTransform и CellView. Это нужно, чтобы отделить примагничивание объекта к клетке на сетке и выделение ее в редакторе, то есть по сути разграничить два разных поведения.
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
}
}
Теперь мы сможем создавать спаун-маркеры для врагов, добавлять к ним компонент SnapTransform и удобно ставить их на сетке без необходимости выделять их как клетку (сами клетки будут иметь оба компонента).
namespace Client {
sealed class SpawnMarker : MonoBehaviour {
public Transform Transform;
public string PrefabName; // имя префаба
public Side Side;
public void Awake () {
Transform = transform;
}
}
}
Как видите, здесь есть поле типа Side. Это простой enum, который обозначает сторону юнита (пользователь/ИИ).
namespace Client {
public enum Side {
User = 0,
Enemy = 1,
}
}
Как вы помните, в прошлой части мы реализовали контекстное меню в классе SceneData для поиска клеток на карте. Сделаем то же самое для спаун маркеров.
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
}
}
Также добавим размер клетки и метод InBounds в GridService, это понадобится нам при инициализации и обработке поведения врагов.
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;
}
}
}
Также добавим поле сущности в компонент клетки - это нужно будет, чтобы определить, какие клетки кем заняты или не заняты вовсе.
namespace Client {
struct Cell {
public CellView View;
public int Entity;
}
}
Давайте обновим компонент Unit. Нужно будет добавить такие данные, как количество очков действия (далее ОД), "инициативу" (это нужно будет при выборе следующего юнита во время смены хода), здоровье, радиус действия (нужно будет для ИИ) и сторону.
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;
}
}
Теперь разберемся с механикой способностей.
Мы будем хранить конфигурацию способности в формате Scriptable Object в Unity. Каждая способность будет иметь своё имя, стоимость очков действия, дистанцию применения и урон.
namespace Client {
[CreateAssetMenu]
public class AbilityConfig : ScriptableObject {
public string Name;
public int ActionPointsCost;
public int Damage;
public int Distance;
}
}
Также удобно будет получать конфиг абилки через какой-нибудь числовой идентификатор. Создадим поле массив Scriptable Object'ов в основном конфиге Configuration.
namespace Client {
[CreateAssetMenu]
sealed class Configuration : ScriptableObject {
public int GridWidth;
public int GridHeight;
public LayerMask UnitLayerMask;
public AbilityConfig[] AbilitiesConfigs;
}
}
Каждый юнит будет иметь компонент HasAbilities со списком сущностей для абилок. Как вы уже поняли, каждая способность будет отдельной сущностью с компонентом Ability, в который мы сохраним стоимость в ОД, сущность владельца, идентификатор, дистанцию и урон.
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;
}
}
Когда нужно будет применить способность, мы будем добавлять к ее сущности компонент Applied и сохранять внутри энтити цели.
namespace Client {
struct Applied {
public int Target;
}
}
Также у каждой способности будет свой уникальный компонент, который, по сути, ее обозначает. Допустим, способность "слабой атаки" будет иметь компонент LightAttack, способность "сильной" - HeavyAttack. Это нужно будет, чтобы у каждой способности была своя логика срабатывания.
Создадим отдельный сервис AbilityHelper, где будем хранить словарь с числом в качестве ключа и коллбеком в качестве значения. По сути мы будем получать ссылку на метод для добавления компонента по идентификатору способности, то есть сопоставим число с типом.
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;
}
}
}
Не забудьте создать экземпляр этого сервиса в стартапе и проинжектить ссылку на него в системы.
Я создал три SO-абилки: LightAttack, PowerShot и HeavyAttack и добавил их в список способностей в главном конфиге.
На экране будут кнопки для способностей, которые мы инстанцируем на старте игры. Кнопка должна хранить индекс способности в массиве, хранящемся в компоненте HasAbilities у юнита игрока. Создадим отдельный монобех AbilityView, в который также сохраним ссылки на TMP названия способности и цифры, соответствующей клавише на клавиатуре для активации способности.
namespace Client {
sealed class AbilityView : MonoBehaviour {
// Ability index for owner.
public int AbilityIdx;
public TextMeshProUGUI Name;
public TextMeshProUGUI KeyIdx;
}
}
Создадим также сервис, который будет хранить активную сторону и активного юнита.
namespace Client {
sealed class RoundService {
public Side ActiveSide;
public readonly int StateMax = 2;
public int ActiveUnit;
public RoundService (Side activeSide) {
ActiveSide = activeSide;
}
}
}
Движемся дальше. Давайте обновим систему инициализации игрока и добавим создание способностей.
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);
}
}
}
}
Как видите, я также создал виджет с компонентом Horizontal Layout Group для выравнивания кнопок и поместил его на канвасе снизу. Чтобы получить к нему доступ в системах, мы можем воспользоваться расширением для uGui и прикрепить NoAction к виджету, указав тип регистрации имени "Awake".
Теперь создадим систему инициализации врагов.
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);
}
}
}
}
Давайте займемся сменой хода.
Как вы помните, мы будем хранить в сервисе RoundService сущность активного юнита (т.е. того, кто сейчас ходит) и его сторону (Side). Ход заканчивается либо когда у активного юнита кончились ОД, либо он пропустил ход (допустим, игрок так захотел или ИИ не стал ничего делать, так как игрок далеко). Когда юнит заканчивает ход, на его сущности будет повешен компонент TurnFinished, а когда все юниты одной стороны заканчивают ход, с них снимается ранее упомянутый флаг и меняется "активная" сторона. Это все нужно будет для того, чтобы корректно найти нового активного юнита.
Внесем изменение в систему движения юнита:
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;
}
}
}
}
И в систему начала движения:
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--;
}
}
}
}
}
Компонент NextTurnEvent - обычное событие, которое нужно, чтобы сменить активного юнита и, возможно, сторону тоже.
Создадим систему смены хода:
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);
}
}
}
Как видите, если все юниты одной стороны завершили ходы и имеют теги TurnFinished, то мы убираем этот тег и меняем сторону. В пределах стороны мы ищем юнита, у которого будет самая маленькая инициатива.
Теперь пришло время к написанию искусственного интеллекта. Работать он будет относительно просто: вражеский юнит должен будет пробовать применить какую-то способность, а если ему не удается использовать ни одну, то он сокращает дистанцию с игроком. Но как определить, может ли он использовать ту или иную способность? Не брать же случайную?
Для этого мы должны придумать алгоритмы проверки, подходит ли та или иная способность в ситуации, в которой сейчас юнит. Мы добавим в сервис AbilityHelper методы для каждой способности, в которой пропишем логику проверки валидации. По аналогии с методами, добавляющими компонент, мы будем получать метод-валидатор через ID абилки. Конечно, понадобятся данные о сетке и юнитах, поэтому обновление сервиса будет серьезным. По сути, мы имплементируем Utility AI.
Валидацию способности мы также будем использовать и для игрока. В момент, когда он нажимает кнопку применения способности, нужно будет проверить с помощью валидатора, может ли он это сделать.
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;
}
}
}
Перед тем, как приступить к написанию логики ИИ, создадим также компонент-ивент Apply для способностей.
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 ());
}
}
}
}
}
}
}
Также дадим возможность игроку использовать способности. Вы помните, что мы создавали кнопки с индексом способности в кэше абилок владельца (игрока). Давайте реализуем механику использования способностей посредством нажатия кнопок. Немного изменим нашу систему UserButtonsInputSystem, добавив туда новый метод и несколько пулов:
[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);
}
}
}
Также немного систему инпута с клавиатуры 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);
}
}
}
}
(вы также можете добавить методы и для остальных клавиш аналогично)
Теперь перейдем к главной части ИИ и создадим систему-роутер, которая будет слать команды активному юниту, если он на стороне врагов.
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);
}
}
}
}
}
}
Общая схема работает таким образом: если в радиусе юнита есть враг (игрок), то нужно проверять валидаторы способностей. Если какая-то способность наносит урон, больший нуля, то нужно ее применить. Если нет, то нужно сокращать дистанцию с игроком (по одной из осей), поворачиваясь в сторону движения, и так пока не сменится активный юнит.
Создадим компонент-ивент о смерти юнита:
namespace Client {
struct DeathEvent { }
}
Теперь создадим системы для способностей:
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);
}
}
}
}
}
Да, вышло очень похоже, но у способностей может быть разная логика. К тому же, гейм дизайнер может потребовать разного поведения в любой момент, поэтому мы оставим это повторение.
Давайте также отобразим на экране количество ОД игрока. Все, что нужно сделать, это создать TMP на канвасе, расположить его там, где вам удобно, проинжектить его через расширение для uGui посредством прикрепления к виджету NoAction, включить регистрацию и обновлять в коде каждый раз, когда меняется у кого-то количество ОД парой строчек:
[EcsUguiNamed (Idents.Ui.PlayerAp)]
readonly TextMeshProUGUI _playerAp = default;
...
if (unit.Side == Side.User) {
_playerAp.text = unit.ActionPoints.ToString ();
}
Осталось только реализовать смерть юнитов. Для этого нам понадобится MonoBehaviour класс UnitView, у которого будет API для переключения анимаций - таким образом мы сможем взаимодействовать с визуалом, не зная подробностей иерархии префаба, т.е. это своего рода уровень абстракции.
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");
}
}
}
Не забудьте прикрепить этот монобех к префабу юнита и сохранить ссылку на него в компоненте Unit в инит-системах игрока и ИИ.
Прекрасно, мы реализовали систему способностей, ходов и сделали интересных врагов, которые сражаются с игроком, используя свои абилки!
Комментарии (7)
JTool
30.06.2022 02:11Всё отлично, но возможно всё таки стоило подекомпозить логику в отдельные методы, для улучшения читаемости, фигачить всё в Run или Update такое себе.
robo2k
Все таки ECS делает всю игровую логику в несколько раз сложней для понимания
supremestranger Автор
Почему? Наоборот, все становится куда проще благодаря слабой связанности и модульности.
robo2k
Как раз благодаря слабой связности непонятно, что происходит. Плюс ECS всегда создает большое количество бойлерплейт кода.
supremestranger Автор
Что именно непонятно? По-моему, все просто. Весь список систем, выполняющихся друг за другом, можно просмотреть в стартап-классе Game. Они друг о друге не знают, коммуникация между разными частями проекта происходит через данные в компонентах и сущности. Как раз эта слабая связанность дает модульность и в итоге легче рефакторить код, изменяя или добавляя новые механики. Также проще смешивать разное поведение с помощью разных наборов компонентов. События тоже проще обрабатывать – просто создаешь сущность с нужным компонентом и ловишь в системе. Сами системы можно легко разделить на несколько или наоборот объединить.
Все, что плохо ложится на ECS (иерархические структуры, например), можно реализовать через сервисы и инжектнуть в системы.
Насчет бойлерплейта - зависит от фреймворка. В LeoECS Lite с расширениями не так уж и много.
robo2k
ECS навязывает очень определенный подход к архитектуре, который подходит к некоторым видам игр вроде RTS, но плохо подходит ко всему остальному.
Вот про иерархические структуры например хорошо сказано, а они в более-менее крупной игре составляют большую часть игровой логики.
То что подсистемы друг о друге не знают, это не так уж и хорошо, потому что придется продумывать комплексную логику их взаимодействия, чтобы это потом не развалилось.
Ну да, в теории на простых примерах можно легко добавлять и удалять разные подсистемы, но в реальной разработке все опять окажется завязано друг на друга, только теперь еще с неявной логикой взаимодействия.
На мой взгляд ECS подход можно использовать только в целях оптимизации и только для некоторых критичных подсистем, а не для всей логики.