Создание шутера с 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 становится все более и более проработанным. В следующей статье мы продолжим реализовывать различные механики и начнем делать врагов.

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

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

Следующая часть


ссылка на оригинал статьи https://habr.com/ru/articles/585058/