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