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