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

от автора

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

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

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


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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *