Друзья, это продолжение серии статей по созданию шутера с использованием фреймворка LeoECS. В этой части мы реализуем несколько новых игровых механик и рассмотрим механизм взаимодействия ECS "мира" с MonoBehaviour-ами.

Перед прочтением этой части не забудьте ознакомиться с
предыдущей.

После реализации движения в игре вы могли заметить, что камера не преследует игрока.

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

public class StaticData : ScriptableObject
{
    public GameObject playerPrefab;
    public float playerSpeed;
    public float smoothTime; // параметр, отвечающий за плавность движения камеры
    public Vector3 followOffset; // оффсет от игрока
}
public class CameraFollowSystem : IEcsRunSystem
{
    private EcsFilter<Player> filter;
    private SceneData sceneData;
    private StaticData staticData;
    // Хранение каких-то данных в системах - не всегда хорошая идея. Но если вы уверены, что больше они нигде не понадобятся, это допустимо.
    private Vector3 currentVelocity; // это поле нужно для работы метода Vector3.SmoothDamp

    public void Run()
    {
        foreach (var i in filter)
        {
            ref var player = ref filter.Get1(i);
            
            var currentPos = sceneData.mainCamera.transform.position;
            currentPos = Vector3.SmoothDamp(currentPos, player.playerTransform.position + staticData.followOffset, ref currentVelocity, staticData.smoothTime);
            sceneData.mainCamera.transform.position = currentPos;
        }
    }
}

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

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

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

Для начала нам нужно понять, откуда взять данные об оружии, чтобы затем заполнить необходимые ECS компоненты. Давайте сохраним их в MonoBehaviour компоненте WeaponSettings.

public class WeaponSettings : MonoBehaviour
{
    public GameObject projectilePrefab;
    public Transform projectileSocket;
    public float projectileSpeed;
    public float projectileRadius;
    public int weaponDamage;
    public int currentInMagazine;
    public int maxInMagazine;
    public int totalAmmo;
}

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

public struct HasWeapon
{
    public EcsEntity weapon;
}

В системе инициализации игрока (PlayerInitSystem) добавим следующие строки:

ref var hasWeapon = ref playerEntity.Get<HasWeapon>();
...
// Копируем данные из MonoBehaviour в компонент мира ECS
var weaponEntity = ecsWorld.NewEntity();
var weaponView = playerGO.GetComponentInChildren<WeaponSettings>();
ref var weapon = ref weaponEntity.Get<Weapon>();
weapon.owner = playerEntity;
weapon.projectilePrefab = weaponView.projectilePrefab;
weapon.projectileRadius = weaponView.projectileRadius;
weapon.projectileSocket = weaponView.projectileSocket;
weapon.projectileSpeed = weaponView.projectileSpeed;
weapon.totalAmmo = weaponView.totalAmmo;
weapon.weaponDamage = weaponView.weaponDamage;
weapon.currentInMagazine = weaponView.currentInMagazine;
weapon.maxInMagazine = weaponView.maxInMagazine;

Прежде чем мы начнем делать механику стрельбы с запуском снарядов и прочего, мы должны рассмотреть одну проблему.

В использованном мною ассете игрок по умолчанию стоит в анимации Idle, и в этом состоянии его оружие опущено вниз. Поэтому при выстреле первая пуля летит ему под ноги. Решить эту проблему можно так: перед стрельбой нужно дождаться момента, пока персонаж не будет в анимации прицеливания. Добиться этого можно разными способами.

Можно сделать StateBehaviour класс для аниматора и повесить его на стейт прицеливания, но при смешивании анимаций метод OnStateEnter будет вызываться раньше времени.
Можно сделать ручной таймер на короткое количество времени перед выстрелом (допустим, подождать 0.1 секунды, и лишь затем начинать стрельбу), однако это не самый надежный способ, и вам придется подбирать правильные значения, которые могут сломаться из-за смешивания анимаций.
И третий вариант заключается в создании Animation Event, который сам будет указывать, в какой момент нам пускать пулю из ствола.

Но Animation Event ничего не знает про наш ECS мир. Он может лишь вызывать методы из MonoBehaviour класса, который прикреплен к текущему GameObject'у, а значит, нам необходимо прокинуть данные из ECS мира в мир MonoBehaviour'ов. Давайте создадим и прикрепим к объекту игрока тонкий класс PlayerView, в который сохраним ссылку на сущность игрока. Нам также необходимо создать в этом классе метод, который отвечает за выстрел оружия:

public class PlayerView : MonoBehaviour
{
    public EcsEntity entity;

    public void Shoot()
    {
        entity.Get<HasWeapon>().weapon.Get<Shoot>();
    }
}
// IEcsIgnoreInFilter - интерфейс для компонентов, которые не имеют никаких полей.
// Слегка повышает скорость работы фреймворка.
public struct Shoot : IEcsIgnoreInFilter
{
}

Теперь нужно прокинуть ссылку на сущность игрока в класс PlayerView. Добавим строку в PlayerInitSystem:

playerGO.GetComponent<PlayerView>().entity = playerEntity;

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

public struct PlayerInputData
{
    public Vector3 moveInput;
    public bool shootInput; // новое поле в компоненте
}

Добавим новую строчку в системе PlayerInputSystem:

input.shootInput = Input.GetMouseButton(0); 

И в системе PlayerAnimationSystem:

player.playerAnimator.SetBool("Shooting", input.shootInput);

Теперь давайте создадим саму систему для выстрела:

public class WeaponShootSystem : IEcsRunSystem
{
    private EcsFilter<Weapon, Shoot> filter;
    
    public void Run()
    {
        foreach (var i in filter)
        {
            ref var weapon = ref filter.Get1(i);

            if (weapon.currentInMagazine > 0)
            {
                weapon.currentInMagazine--;
                ref var entity = ref filter.GetEntity(i);
                ref var spawnProjectile = ref entity.Get<SpawnProjectile>();
                entity.Del<Shoot>();
            }
        }
    }
}
// Компонент-событие, сообщающее о необходимости выпустить пулю
public struct SpawnProjectile : IEcsIgnoreInFilter
{
}

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

Теперь давайте напишем систему для создания пули:

public class SpawnProjectileSystem : IEcsRunSystem
{
    private EcsFilter<Weapon, SpawnProjectile> filter;
    private EcsWorld ecsWorld;
    
    public void Run()
    {
        foreach (var i in filter)
        {
            ref var weapon = ref filter.Get1(i);
            
            // Создаем GameObject пули и ее сущность
            var projectileGO = Object.Instantiate(weapon.projectilePrefab, weapon.projectileSocket.position, Quaternion.identity);
            var projectileEntity = ecsWorld.NewEntity();

            ref var projectile = ref projectileEntity.Get<Projectile>();

            projectile.damage = weapon.weaponDamage;
            projectile.direction = weapon.projectileSocket.forward;
            projectile.radius = weapon.projectileRadius;
            projectile.speed = weapon.projectileSpeed;
            projectile.previousPos = projectileGO.transform.position;
            projectile.projectileGO = projectileGO;

            ref var entity = ref filter.GetEntity(i);
            entity.Del<SpawnProjectile>();
        }
    }
}

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

public class ProjectileMoveSystem : IEcsRunSystem
{
    private EcsFilter<Projectile> filter;
    
    public void Run()
    {
        foreach (var i in filter)
        {
            ref var projectile = ref filter.Get1(i);
            
            var position = projectile.projectileGO.transform.position;
            position += projectile.direction * projectile.speed * Time.deltaTime;
            projectile.projectileGO.transform.position = position;
            
            var displacementSinceLastFrame = position - projectile.previousPos;
            var hit = Physics.SphereCast(projectile.previousPos, projectile.radius,
                displacementSinceLastFrame.normalized, out var hitInfo, displacementSinceLastFrame.magnitude);
            if (hit)
            {
                ref var entity = ref filter.GetEntity(i);
                ref var projectileHit = ref entity.Get<ProjectileHit>();
                projectileHit.raycastHit = hitInfo;
            }

            projectile.previousPos = projectile.projectileGO.transform.position;
        }
    }
}

Осталось лишь добавить систему обработки самого попадания пули:

public class ProjectileHitSystem : IEcsRunSystem
{
    private EcsFilter<Projectile, ProjectileHit> filter;
    
    public void Run()
    {
        foreach (var i in filter)
        {
            ref var projectile = ref filter.Get1(i);
            
            projectile.projectileGO.SetActive(false);
            // Здесь немного пустовато. Мы добавим больше функционала в новых частях
        }
    }
}

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

Нам нужно внести корректировки в системы пользовательского ввода и стрельбы, а также создать новый компонент TryReload:

public class PlayerInputSystem : IEcsRunSystem
{
    private EcsFilter<PlayerInputData, HasWeapon> filter;
    
    public void Run()
    {
        foreach (var i in filter)
        {
            ref var input = ref filter.Get1(i);
            ref var hasWeapon = ref filter.Get2(i); // текущее оружие

            input.moveInput = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
            input.shootInput = Input.GetMouseButton(0);
            if (Input.GetKeyDown(KeyCode.R))
            {
                ref var weapon = ref hasWeapon.weapon.Get<Weapon>();

                if (weapon.currentInMagazine < weapon.maxInMagazine) // если патронов недостаточно, то начать перезарядку
                {
                    ref var entity = ref filter.GetEntity(i);
                    entity.Get<TryReload>();
                }
            }
        }
    }
}
public class WeaponShootSystem : IEcsRunSystem
{
    private EcsFilter<Weapon, Shoot> filter;
    
    public void Run()
    {
        foreach (var i in filter)
        {
            ref var weapon = ref filter.Get1(i);

            ref var entity = ref filter.GetEntity(i);
            entity.Del<Shoot>();
            
            if (weapon.currentInMagazine > 0)
            {
                weapon.currentInMagazine--;
                
                ref var spawnProjectile = ref entity.Get<SpawnProjectile>();
            }

            else // если патронов нет, начать перезарядку
            { 
                ref var reload = ref entity.Get<TryReload>();
            }
        }
    }
}

Теперь нужно создать систему для перезарядки. Скорее всего, необходимость перезаряжаться будет и у игрока, и у врагов. Системе, связанной с этой механикой, нужно будет получить доступ к аниматору юнита. Пока что мы храним аниматор игрока в компоненте Player, но теперь, так как нам нужна дополнительная фильтрация, мы можем вынести аниматор в общий компонент AnimatorRef, который будет иметься и у игрока, и у врагов. Таким образом мы сможем унифицировать логику перезарядки для всех юнитов.

public struct AnimatorRef
{
    public Animator animator;
}
// Новые строки в PlayerInitSystem
ref var animatorRef = ref playerEntity.Get<AnimatorRef>();
...
animatorRef.animator = player.playerAnimator;

Нам также нужно будет зафиксировать конец анимации перезарядки, чтобы обновить боезапас. Мы можем создать Animation Event и повесить его на последний кадр перезарядки анимации. Он будет вешать компонент, сообщающий о конце перезарядки.

Создадим новый метод в классе PlayerView:

public void Reload()
{
    entity.Get<HasWeapon>().weapon.Get<ReloadingFinished>();
}

И саму систему для перезарядки:

public class ReloadingSystem : IEcsRunSystem
{
    private EcsFilter<TryReload, AnimatorRef> tryReloadFilter;
    private EcsFilter<Weapon, ReloadingFinished> reloadingFinishedFilter;
    
    public void Run()
    {
        foreach (var i in tryReloadFilter)
        {
            ref var animatorRef = ref tryReloadFilter.Get2(i);

            animatorRef.animator.SetTrigger("Reload");

            ref var entity = ref tryReloadFilter.GetEntity(i);
            entity.Del<TryReload>();
        }

        foreach (var i in reloadingFinishedFilter)
        {
            ref var weapon = ref reloadingFinishedFilter.Get1(i);
            
            // Вычисляем, сколько патронов нам нужно
            var needAmmo = weapon.maxInMagazine - weapon.currentInMagazine;
            weapon.currentInMagazine = (weapon.totalAmmo >= needAmmo)
                ? weapon.maxInMagazine
                : weapon.currentInMagazine + weapon.totalAmmo;
            weapon.totalAmmo -= needAmmo;
            weapon.totalAmmo = weapon.totalAmmo < 0
                ? 0
                : weapon.totalAmmo;

            ref var entity = ref reloadingFinishedFilter.GetEntity(i);
            entity.Del<ReloadingFinished>();
        }
    }
}

Отлично, теперь мы можем перезаряжаться.

Помните, что все методы, описанные в статьях, не являются единственными верными решениями каких-то проблем. В первую очередь, они должны помочь вам додуматься до каких-то подходов, натолкнуть на некоторые мысли и научиться строить архитектуру кода с LeoECS наиболее эффективным путем.

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

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


  1. AntonGre4ka
    01.10.2021 21:33
    +1

    круто, спасибо) надеюсь будет еще


    1. supremestranger Автор
      01.10.2021 21:34

      Рад, что понравилось :)

      Да, продолжение будет.


  1. Crazyscrat
    06.10.2021 00:35
    +1

    Огромное спасибо. Так мало уроков по ECS, изучаю по твоим и тому что есть в инете (мало). Параллельно делаю проект 2D на Leo ECS.


    1. supremestranger Автор
      06.10.2021 00:36

      Рад стараться!


  1. RedFox2020
    06.10.2021 00:35
    +1

    Хорошая статья и сам подход Ecs нравится, но у меня возник вопрос: Как реализовать OnTriggerEnter в leoecs?

    Я понял, что нужно создавать entity и вешать на него комнонетн, но если я хочу чтобы на entity персонажа крепится компонент, при вхождение в триггер другим объектом, например пулей

    Как это можно сделать?


    1. supremestranger Автор
      06.10.2021 00:41

      Чтобы "поймать" события штатной физики Unity, придется создавать тонкий MonoBehaviour класс и вешать его на GameObject. Внутри будет метод с нужным событием (OnTriggerEnter, например), а в нем уже как-то прокидываются события о физике в ECS. Например, метод может создать новую отдельную сущность с компонентом TriggerEnter и какими-то данными внутри, а может просто добавит компонент к уже существующей сущности. Прокинуть данные о ECS в MonoBehaviour можно тоже по-разному. Можно вручную заполнить поля в Init-системе, можно поместить их в сервис-локатор или синглтон.

      Советую посмотреть вот это расширение. Я написал его, чтобы не приходилось каждый раз создавать MonoBehaviour класс для событий физики.

      А еще можно посмотреть вот этот проект, там я прокидываю события физики в ECS без расширения.