Друзья, в этой запоздалой части туториала мы создадим врагов, научим их двигаться и атаковать игрока.

Обязательно прочитайте прошлую часть, если еще не сделали этого.

Первым делом нам нужно запечь навмеш для навигации врагов и подготовить их визуал. Начнем создание новых механик с инициализации врагов. Для этого создадим систему EnemyInitSystem, которая проанализирует сцену на наличие врагов, создаст их сущности и заполнит данные в компонентах.

Для того, чтобы как-то найти врагов на сцене, используем для этого EnemyView - класс MonoBehaviour, который будет прикреплен к объектам врагов.

public class EnemyView : MonoBehaviour
{
    public NavMeshAgent navMeshAgent;
    public Animator animator;
    public float meleeAttackDistance;
    public float triggerDistance;
    public float meleeAttackInterval;
    public int startHealth;
    public int damage;
}
public struct Enemy
{
    public NavMeshAgent navMeshAgent;
    public Animator animator;
    public Transform transform;
    public float meleeAttackDistance;
    public float triggerDistance;
    public float meleeAttackInterval;
    public int damage;
}
public class EnemyInitSystem : IEcsInitSystem
{
    private EcsWorld ecsWorld;
    
    public void Init()
    {
        foreach (var enemyView in Object.FindObjectsOfType<EnemyView>())
        {
            var enemyEntity = ecsWorld.NewEntity();

            ref var enemy = ref enemyEntity.Get<Enemy>();
            ref var health = ref enemyEntity.Get<Health>();

            health.value = enemyView.startHealth;
            enemy.damage = enemyView.damage;
            enemy.meleeAttackDistance = enemyView.meleeAttackDistance;
            enemy.navMeshAgent = enemyView.navMeshAgent;
            enemy.transform = enemyView.transform;
            enemy.meleeAttackInterval = enemyView.meleeAttackInterval;
            enemy.triggerDistance = enemyView.triggerDistance;
            animatorRef.animator = enemyView.animator;
        }
    }
}

Также мы создадим новый компонент Health, который будет хранить единственное поле - здоровье юнита.

public struct Health
{
    public int value;
}

Паттерн поведения врага будет простой: он спокойно стоит на месте, а когда игрок подходит близко, он начинает бежать к нему. Когда расстояние между врагом и игроком становится маленьким, враг начинает атаковать последнего с определенной частотой. Все параметры хранятся в компонентах, а брать их можно из конфигов, которые могут храниться совсем по-разному: например, с помощью сервиса Google документы, или в Scriptable Object'ах, но для простоты мы будем хранить их сразу в MonoBehaviour'ах и слать в компоненты.

Забегая вперед, хочется обсудить проблему, которая будет нас ожидать, когда мы начнем реализовывать стрельбу во врагов. Чтобы определить, попал ли снаряд во врага, очевидно, мы будем использовать рейкаст. Но рейкаст ничего не знает о ECS и может дать информацию лишь об объектах Unity и их данных (компонентах или тегах). Чтобы понять, попали ли мы во врага, мы можем проверить наличие компонента EnemyView у объекта, в который попал снаряд. Далее нам нужно будет создать событие о том, что сущность врага была атакована и уменьшить ее здоровье в компоненте Health, и в этот момент мы задаемся вопросом: "Как определить EcsEntity, зная GameObject?"

Вариантов всего три:

  • Пробежаться по всем нужным сущностям и сравнивать их геймобжект (лежащий в каком-то из компонентов, например View) с имеющимся - не самый удобный способ.

  • Хранить ссылку на EcsEntity внутри какого-нибудь тонкого монобеха (в нашем случае EnemyView), который получим через gameobject.GetComponent<>() - быстро и легко. Главное - заполнить эту ссылку при создании энтити.

  • Создать сервис со словарем <GameObject, EcsEntity> и получать сущность оттуда. Наверное, работать это будет быстрее всего (в билде), но это не очень удобно. Надо также следить за целостностью этого кэша.

Давайте воспользуемся вторым способом, так как на практике разработчики чаще всего делают именно так. Нужно будет немного изменить класс EnemyView и систему инициализации врагов, добавив пару строчек кода.

public class EnemyView : MonoBehaviour
{
    ...
    public EcsEntity entity;
    ...
}
public class EnemyInitSystem : IEcsInitSystem
{
    private EcsWorld ecsWorld;
    
    public void Init()
    {
        foreach (var enemyView in Object.FindObjectsOfType<EnemyView>())
        {
            var enemyEntity = ecsWorld.NewEntity();
            ...
            enemyEntity.Get<Idle>();
            enemyView.entity = enemyEntity;
            ...
        }
    }
}

Также давайте сразу повесим на сущность врага компонент Idle, означающий, что враг пока что ничего не делает.

public struct Idle : IEcsIgnoreInFilter
{
}

Новая строчка в системе инициализации:

...
enemyEntity.Get<Idle>();
...

Так как игрок у нас один, мы могли бы сохранить ссылку на его сущность в RuntimeData, чтобы враги могли достать с помощью нее позицию игрока из компонента. Не забудьте присвоить ей значение в системе инициализации игрока.

public class EnemyIdleSystem : IEcsRunSystem
{
    private EcsFilter<Enemy, AnimatorRef, Idle> calmEnemies;
    private RuntimeData runtimeData;
    
    public void Run()
    {
        foreach (var i in calmEnemies)
        {
            ref var enemy = ref calmEnemies.Get1(i);
            ref var player = ref runtimeData.playerEntity.Get<Player>();
            ref var animatorRef = ref calmEnemies.Get2(i);

            if ((enemy.transform.position - player.playerTransform.position).sqrMagnitude <= enemy.triggerDistance * enemy.triggerDistance)
            {
                ref var entity = ref calmEnemies.GetEntity(i);
                entity.Del<Idle>();
                ref var follow = ref entity.Get<Follow>();
                follow.target = runtimeData.playerEntity;
                animatorRef.animator.SetBool("Running", true);
            }
        }
    }
}
public struct Follow
{
    public EcsEntity target;
    public float nextAttackTime;
}

Так как мы сохранили в компоненте Follow преследуемую сущность, нам нужно как-то узнать ее позицию. Давайте создадим компонент TransformRef, добавим его к сущности игрока и сохраним ссылку на его трансформ.

Мы, конечно, могли бы попробовать получить компонент Player с сущности-таргета и взять позицию оттуда, но что, если мы захотим преследовать не игрока, а что-то еще? Для этого мы выделим Transform в отдельный компонент.

public struct TransformRef
{
    public Transform transform;
}

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

public class EnemyFollowSystem : IEcsRunSystem
{
    private EcsFilter<Enemy, Follow, AnimatorRef> followingEnemies;
    private RuntimeData runtimeData;
    private EcsWorld ecsWorld;
        
    public void Run()
    {
        foreach (var i in followingEnemies)
        {
            ref var enemy = ref followingEnemies.Get1(i);
            ref var follow = ref followingEnemies.Get2(i);
            ref var animatorRef = ref followingEnemies.Get3(i);

            // при работе с сущностями нужно всегда сначала удостовериться, не уничтожены ли они
            if (!follow.target.IsAlive())
            {
                ref var entity = ref followingEnemies.GetEntity(i);
                animatorRef.animator.SetBool("Running", false);
                entity.Del<Follow>();
                continue;
            }
            
            ref var transformRef = ref follow.target.Get<TransformRef>();
            var targetPos = transformRef.transform.position;
            enemy.navMeshAgent.SetDestination(targetPos);
            var direction = (targetPos - enemy.transform.position).normalized;
            direction.y = 0f;
            enemy.transform.forward = direction;

            if ((enemy.transform.position - transformRef.transform.position).sqrMagnitude <
                enemy.meleeAttackDistance * enemy.meleeAttackDistance && Time.time >= follow.nextAttackTime)
            {
                follow.nextAttackTime = Time.time + enemy.meleeAttackInterval;
                animatorRef.animator.SetTrigger("Attack");
                ref var e = ref ecsWorld.NewEntity().Get<DamageEvent>();
                e.target = follow.target;
                e.value = enemy.damage;
            }
        }
    }
}

Вы могли заметить, что я вынес событие об уроне в отдельную сущность. Так как игрок может быть атакован дважды за фрейм, а два компонента одинаковых типа на сущности вы иметь не можете (да и не особо это нужно), то надо либо выносить компонент на отдельную сущность и хранить внутри таргет, либо придумать, как сделать накопление с одним компонентом (допустим, создать внутри список или просто увеличивать какое-то значение). Я выбрал первый вариант.

Теперь давайте сделаем так, чтобы наши пули наносили урон врагам. Вернемся в уже созданную систему ProjectileHitSystem и внесем определенные корректировки.

public class ProjectileHitSystem : IEcsRunSystem
{
    private EcsFilter<Projectile, ProjectileHit> filter;
    private EcsWorld ecsWorld;
    
    public void Run()
    {
        foreach (var i in filter)
        {
            ref var projectile = ref filter.Get1(i);
            ref var hit = ref filter.Get2(i);

            if (hit.raycastHit.collider.gameObject.TryGetComponent(out EnemyView enemyView))
            {
                if (enemyView.entity.IsAlive())
                {
                    ref var e = ref ecsWorld.NewEntity().Get<DamageEvent>();
                    e.target = enemyView.entity;
                    e.value = projectile.damage;
                }
            }

            projectile.projectileGO.SetActive(false);
            filter.GetEntity(i).Destroy();
        }
    }
}

Создадим систему нанесения урона (вычитания здоровья):

public class DamageSystem : IEcsRunSystem
{
    private EcsFilter<DamageEvent> damageEvents;
    
    public void Run()
    {
        foreach (var i in damageEvents)
        {
            ref var e = ref damageEvents.Get1(i);
            ref var health = ref e.target.Get<Health>();

            health.value -= e.value;

            // если таргет мертв
            if (health.value <= 0)
            {
                e.target.Get<DeathEvent>();
            }

            damageEvents.GetEntity(i).Destroy();
        }
    }
}

И систему смерти врагов:

public class EnemyDeathSystem : IEcsRunSystem
{
    private EcsFilter<Enemy, DeathEvent, AnimatorRef> deadEnemies;
    
    public void Run()
    {
        foreach (var i in deadEnemies)
        {
            ref var animatorRef = ref deadEnemies.Get3(i);
            
            animatorRef.animator.SetTrigger("Death");

            ref var entity = ref deadEnemies.GetEntity(i);
            entity.Destroy();
        }
    }
}

А также систему смерти игрока:

public class PlayerDeathSystem : IEcsRunSystem
{
    private EcsFilter<Player, DeathEvent, AnimatorRef> deadPlayers;
    private RuntimeData runtimeData;
    private UI ui;
    
    public void Run()
    {
        foreach (var i in deadPlayers)
        {
            ref var animatorRef = ref deadPlayers.Get3(i);
            
            animatorRef.animator.SetTrigger("Death");
            ui.deathScreen.Show();
            runtimeData.gameOver = true;
            
            deadPlayers.GetEntity(i).Destroy();
        }
    }
}

Давайте также сделаем перезагрузку игры на кнопку паузы. Для этого я создал поле gameOver в классе RuntimeData. Внесем корректировки в систему паузы:

internal class PauseSystem : IEcsRunSystem
{
    private EcsFilter<PauseEvent> filter;
    private RuntimeData runtimeData;
    private UI ui;
    
    public void Run()
    {
        foreach (var i in filter)
        {
            filter.GetEntity(i).Del<PauseEvent>();

            if (runtimeData.gameOver)
            {
                SceneManager.LoadScene(SceneManager.GetActiveScene().name);
                continue;
            }
            
            runtimeData.isPaused = !runtimeData.isPaused;
            Time.timeScale = runtimeData.isPaused ? 0f : 1f;
            ui.pauseScreen.Show(runtimeData.isPaused);
        }
    }
}

Итак, мы реализовали простейшее поведение врагов и механики дамага.

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

Туториал подготовлен в соавторстве с Владимиром Роттердамским

Ссылка на репозиторий с проектом