Создание шутера с LeoECS. Часть 3

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

Не забудьте прочитать прошлую часть перед прочтением этой.

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

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

public struct Reloading : IEcsIgnoreInFilter
{
}

Компонент TryReload должен гарантированно удаляться с сущности, так как он лишь говорит о том, что юнит предпринял попытку перезарядиться. А вот начнется ли перезарядка - зависит от наличия компонента Reloading: если он есть, то перезарядка начинаться не должна, если его нет - должна.

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

                animatorRef.animator.SetTrigger("Reload");

                ref var entity = ref notReloadingFilter.GetEntity(i);
                entity.Get<Reloading>();
            }
            tryReloadFilter.GetEntity(j).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);
            weapon.owner.Del<Reloading>();
            entity.Del<ReloadingFinished>();
        }
    }
}

Вложенные циклы... выглядит не очень, не так ли? Особенно учитывая, что внешний цикл нужен лишь для того, чтобы удалить компонент с сущности.

Мы можем воспользоваться штатной функцией LeoECS, которая называется EcsSystems.OneFrame. Она позволяет в какой-то момент цикла систем удалить определенный компонент со всех сущностей, у которых он есть. (соответственно, если компонент был единственный - сущность удаляется вместе с ним)

Давайте будем удалять все компоненты TryReload перед системой пользовательского ввода, ведь именно там он вешается на сущность. Теперь система перезарядки будет выглядеть так:

public class ReloadingSystem : IEcsRunSystem
{
    private EcsFilter<TryReload, AnimatorRef>.Exclude<Reloading> 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.Get<Reloading>();
        }

        foreach (var i in reloadingFinishedFilter)
        {
            ref var weapon = ref reloadingFinishedFilter.Get1(i);
            ...
            ...
        }
    }
}

А стартап так:

...
private void Start()
{
    ecsWorld = new EcsWorld();
    updateSystems = new EcsSystems(ecsWorld);
    fixedUpdateSystems = new EcsSystems(ecsWorld);
    RuntimeData runtimeData = new RuntimeData();
#if UNITY_EDITOR
    Leopotam.Ecs.UnityIntegration.EcsWorldObserver.Create (ecsWorld);
    Leopotam.Ecs.UnityIntegration.EcsSystemsObserver.Create (updateSystems);
#endif
    updateSystems
        .Add(new PlayerInitSystem())
        .OneFrame<TryReload>()
        .Add(new PlayerInputSystem())
        .Add(new PlayerRotationSystem())
        .Add(new PlayerAnimationSystem())
        .Add(new WeaponShootSystem())
        .Add(new SpawnProjectileSystem())
        .Add(new ProjectileMoveSystem())
        .Add(new ProjectileHitSystem())
        .Add(new ReloadingSystem())
        .Inject(configuration)
        .Inject(sceneData)
        .Inject(runtimeData);
...

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

Теперь нужно исправить другой баг, тоже связанный с анимациями. Если вы начнете перезаряжаться на ходу, вы заметите, что все тело юнита перешло в анимацию перезарядки. В том числе и ноги, которые стоят на месте, пока персонаж движется. Решается эта проблема созданием двух слоев в Аниматоре - один для верхних частей тела, другой для нижних.

Основной слой аниматора мы оставим как есть, а новый создадим для нижних частей тела и назовем Lowerbody. Назначим соответствующую Avatar Mask, которая влияет лишь на ноги, и назначим ее во втором слое аниматора.

Так как я использую ассет, в котором анимации оказались не подготовлены для блендинга, результат вышел у меня странный, но если контент сделан правильно, все будет работать как надо. Это касается также и логики стрельбы, которую я реализовал через Animation Event. Если вы готовите контент по-другому и разделяете анимации, вам необязательно реализовывать стрельбу именно так.

Теперь мы можем перейти к созданию UI в нашем проекте.

Прежде всего нужно понять, что не все части проекта должны быть написаны с ECS. Да, он позволяет нам удобно писать и рефакторить игровую логику, но ECS - это про линейный процессинг. Иерархические структуры, так или иначе связанные с графами, плохо ложатся на него. К ним относятся FSM, GOAP, Behaviour/Decision tree, UI и многое другое. Поэтому лучше реализовывать эти структуры в виде сервисов и внедрять их в ECS в дальнейшем.

Нам нужно будет как ловить и обрабатывать события UI (например, при нажатии на кнопку и т.д.), так и иметь возможность как-то менять его (открыть/закрыть поп-ап, изменить лейбл и прочее). Для обработки событий мы можем использовать расширение фреймворка для работы с UI, созданное самим автором, а для изменения частей интерфейса мы должны создавать отдельные классы для различных элементов (попапов и прочего) и внедрять их в системы LeoECS.

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

Первым делом создадим MonoBehaviour компонент UI, который будет висеть на канвасе.

public class UI : MonoBehaviour
{
}

А также абстрактный класс Screen.

public abstract class Screen : MonoBehaviour
{
    public virtual void Show(bool state = true)
    {
        gameObject.SetActive(state);
    }
}

Займемся самим дизайном пользовательского интерфейса. Пока что нам будет достаточно меню паузы и экрана игры со счетчиком патронов.

  • Canvas - сам UI

  • EventSystem - объект, обрабатывающий события

  • GameScreen - пустой объект, экран игры

  • CurrentMagazineInLabel - лейбл для текущего количества патронов в обойме

  • SlashLabel - лейбл для разделения двух соседних

  • TotalAmmoLabel - лейбл для всех патронов

  • PauseScreen - пустой объект, меню паузы

  • BackgroundPanel - полупрозрачный темный спрайт

  • PauseLabel - лейбл с надписью "PAUSED"

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

using TMPro;

public class GameScreen : Screen
{
    public TextMeshProUGUI currentInMagazineLabel;
    public TextMeshProUGUI totalAmmoLabel;
}
public class PauseScreen : Screen
{
}
public class UI : MonoBehaviour
{
    public GameScreen gameScreen;
    public PauseScreen pauseScreen;
}

Не забудьте также создать поле типа UI в классе EcsStartup...

public class EcsStartup : MonoBehaviour
{
    public StaticData configuration;
    public SceneData sceneData;
    public UI ui;

    private EcsWorld ecsWorld;
    private EcsSystems updateSystems;
    private EcsSystems fixedUpdateSystems;
    ...

...а также вручную заполнить поле в инспекторе объектом Canvas и внедрить экземпляр в цикл updateSystems:

updateSystems
            .Add(new PlayerInitSystem())
            .OneFrame<TryReload>()
            .Add(new PlayerInputSystem())
            ...
            .Add(new ReloadingSystem())
            .Inject(configuration)
            .Inject(sceneData)
            .Inject(ui)
            .Inject(runtimeData);

Сделаем так, чтобы когда игрок стрелял, UI элементы для патронов обновлялись. Также необходимо сделать им инициализацию на старте.

Добавим пару новых строк в PlayerInitSystem:

ui.gameScreen.currentInMagazineLabel.text = weapon.currentInMagazine.ToString();
ui.gameScreen.totalAmmoLabel.text = weapon.totalAmmo.ToString();

И в WeaponShootSystem:

public class WeaponShootSystem : IEcsRunSystem
{
    private EcsFilter<Weapon, Shoot> filter;
    private UI ui;
    
    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--;
                // проверяем, игрок ли стреляет
                if (weapon.owner.Has<Player>())
                {
                    ui.gameScreen.currentInMagazineLabel.text = weapon.currentInMagazine.ToString();
                    ui.gameScreen.totalAmmoLabel.text = weapon.totalAmmo.ToString();
                }
                ...

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

ui.gameScreen.SetAmmo(weapon.currentInMagazine, weapon.totalAmmo);
public class GameScreen : Screen
{
    // Для инкапсуляции мы можем даже сделать поля приватными и пометить атрибутом SerializeField, чтобы они были видны в инспекторе
    [SerializeField] private TextMeshProUGUI currentInMagazineLabel;
    [SerializeField] private TextMeshProUGUI totalAmmoLabel;

    public void SetAmmo(int current, int total)
    {
        currentInMagazineLabel.text = current.ToString();
        totalAmmoLabel.text = total.ToString();
    }
}

Также необходимо вызывать этот метод в системе ReloadingSystem при окончании перезарядки:

...
ref var entity = ref reloadingFinishedFilter.GetEntity(i);
if (weapon.owner.Has<Player>())
{
    ui.gameScreen.SetAmmo(weapon.currentInMagazine, weapon.totalAmmo);
}
weapon.owner.Del<Reloading>();
entity.Del<ReloadingFinished>();
...

Теперь нужно найти применение для меню паузы, которое мы создали. При нажатии на клавишу Escape нужно приостановить игру и показать его, а при повторном - убрать и продолжить игру. Давайте создадим булеву переменную isPaused и поместим ее в наш шаренный стейт - RuntimeData.

public class RuntimeData
{
    public bool isPaused = false;
}

Немного модифицируем систему пользовательского ввода.

...
if (Input.GetKeyDown(KeyCode.Escape))
{
    ecsWorld.NewEntity().Get<PauseEvent>();
}
public struct PauseEvent : IEcsIgnoreInFilter
{
}

И создадим новую систему для паузы.

public 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>();
            runtimeData.isPaused = !runtimeData.isPaused;
            Time.timeScale = runtimeData.isPaused ? 0f : 1f;
            ui.pauseScreen.Show(runtimeData.isPaused);
        }
    }
}

Есть только одна проблема. Даже если игра на паузе, наш персонаж будет поворачиваться в сторону мыши. Решим эту проблему так:

public class PlayerRotationSystem : IEcsRunSystem
{
    private EcsFilter<Player> filter;
    private SceneData sceneData;
    private RuntimeData runtimeData;

    public void Run()
    {
        if (runtimeData.isPaused) return;
        foreach (var i in filter)
        {
            ref var player = ref filter.Get1(i);
            ...

Прекрасно! Теперь игру можно поставить на паузу.

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

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