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