Создание шутера с LeoECS. Часть 3
Друзья, в этой части серии статей мы исправим некоторые баги, возникшие после изменений в предыдущей части, начнем готовить UI и приступим к новым механикам.
Не забудьте прочитать прошлую часть перед прочтением этой.
Прежде всего, давайте исправим все баги, связанные с перезарядкой. Если вы начнете перезаряжаться и вновь нажмете на кнопку перезарядки, персонаж начнет делать это заново. Такое поведение нужно исправить.
Давайте создадим компонент-флаг Reloading, который будет висеть на сущности перезаряжающегося юнита, и включим его в Exclude констрейнт в фильтре системы перезарядки, а также введем некоторые изменения в логику этой системы.
public struct Reloading : IEcsIgnoreInFilter
{
}
Компонент TryReload должен гарантированно удаляться с сущности, так как он лишь говорит о том, что юнит предпринял попытку перезарядиться. А вот начнется ли перезарядка — зависит от наличия компонента Reloading: если он есть, то перезарядка начинаться не должна, если его нет — должна.
public class ReloadingSystem : IEcsRunSystem
{
private EcsFilter tryReloadFilter;
private EcsFilter.Exclude notReloadingFilter;
private EcsFilter 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();
}
tryReloadFilter.GetEntity(j).Del();
}
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();
entity.Del();
}
}
}
Вложенные циклы… выглядит не очень, не так ли? Особенно учитывая, что внешний цикл нужен лишь для того, чтобы удалить компонент с сущности.
Мы можем воспользоваться штатной функцией LeoECS, которая называется EcsSystems.OneFrame. Она позволяет в какой-то момент цикла систем удалить определенный компонент со всех сущностей, у которых он есть. (соответственно, если компонент был единственный — сущность удаляется вместе с ним)
Давайте будем удалять все компоненты TryReload перед системой пользовательского ввода, ведь именно там он вешается на сущность. Теперь система перезарядки будет выглядеть так:
public class ReloadingSystem : IEcsRunSystem
{
private EcsFilter.Exclude tryReloadFilter;
private EcsFilter 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();
}
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()
.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()
.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 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();
if (weapon.currentInMagazine > 0)
{
weapon.currentInMagazine--;
// проверяем, игрок ли стреляет
if (weapon.owner.Has())
{
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())
{
ui.gameScreen.SetAmmo(weapon.currentInMagazine, weapon.totalAmmo);
}
weapon.owner.Del();
entity.Del();
...
Теперь нужно найти применение для меню паузы, которое мы создали. При нажатии на клавишу Escape нужно приостановить игру и показать его, а при повторном — убрать и продолжить игру. Давайте создадим булеву переменную isPaused и поместим ее в наш шаренный стейт — RuntimeData.
public class RuntimeData
{
public bool isPaused = false;
}
Немного модифицируем систему пользовательского ввода.
...
if (Input.GetKeyDown(KeyCode.Escape))
{
ecsWorld.NewEntity().Get();
}
public struct PauseEvent : IEcsIgnoreInFilter
{
}
И создадим новую систему для паузы.
public class PauseSystem : IEcsRunSystem
{
private EcsFilter filter;
private RuntimeData runtimeData;
private UI ui;
public void Run()
{
foreach (var i in filter)
{
filter.GetEntity(i).Del();
runtimeData.isPaused = !runtimeData.isPaused;
Time.timeScale = runtimeData.isPaused ? 0f : 1f;
ui.pauseScreen.Show(runtimeData.isPaused);
}
}
}
Есть только одна проблема. Даже если игра на паузе, наш персонаж будет поворачиваться в сторону мыши. Решим эту проблему так:
public class PlayerRotationSystem : IEcsRunSystem
{
private EcsFilter 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 становится все более и более проработанным. В следующей статье мы продолжим реализовывать различные механики и начнем делать врагов.
→ Ссылка на репозиторий с проектом