Модульные механики на Unity

от автора

Привет, Хабр! Меня зовут Игорь, и я Unity Developer. Сегодня я покажу, как можно реализовывать модульные механики для игровых объектов, разделяя данные и логику.

Если вы не читали мою статью про атомарно-ориентированный дизайн, то рекомендую ознакомиться с ней. Но это опционально.

Цель статьи — наглядно показать, как можно гибко описывать и переиспользовать поведение игровых объектов в Unity, поэтому присаживайтесь к огоньку.

Для лучшего понимания я подготовил простой пример на кубах — это персонаж и пуля.

Наши подопытные в Unity

Наши подопытные в Unity

У персонажа будет здоровье и возможность перемещаться, а у пули — лететь и наносить урон при попадании в персонажа. На этом примере попытаюсь раскрыть прелесть разделения данных и логики. Ну шо, поехали 🙂

Реализация персонажа

В атомарном подходе любой игровой объект состоит из данных и логики. Под данными я подразумеваю любую информацию, которую хранит объект (поля / свойства / события), под логикой — алгоритмы (методы, функции / процедуры), которые обрабатывают эту информацию и изменяют состояние персонажа. Да, это ECS, только объектно-ориентированный 🙂

Итак, персонаж имеет параметр здоровья и механику получения урона, опишем это:

public sealed class Character : MonoBehaviour {     //Данные:     public AtomicEvent<int> takeDamageEvent;     public AtomicVariable<int> hitPoints;          //Логика:     private TakeDamageMechanics takeDamageMechanics; }
  • Поле takeDamageEvent типа AtomicEvent<T> является событием получения урона

  • Поле hitPoints типа AtomicVariable<T> содержит текущее кол-во здоровья и оповещает о его изменении.

  • Поле takeDamageMechanics обрабатывает событие получения урона и уменьшает кол-во здоровья персонажа.

Классы AtomicEvent & AtomicVariable являются универсальными классами, которые можно использовать для описания свойств любого игрового объекта:

[Serializable] public sealed class AtomicEvent<T> {     private event Action<T> onEvent;      public void Invoke(T args)     {         this.onEvent?.Invoke(args);     }          public void Subscribe(Action<T> action)     {         this.onEvent += action;     }      public void Unsubscribe(Action<T> action)     {         this.onEvent -= action;     } }
[Serializable] public sealed class AtomicVariable<T> {     private event Action<T> onValueChanged;      public T Value     {         get { return this.value; }         set         {             this.value = value;             this.onValueChanged?.Invoke(value);         }     }      [SerializeField]     private T value;      public AtomicVariable()     {     }      public AtomicVariable(T value)     {         this.value = value;     }      public void Subscribe(Action<T> action)     {         this.onValueChanged += action;     }      public void Unsubscribe(Action<T> action)     {         this.onValueChanged -= action;     } }

Класс TakeDamageMechanics является элементом логики, который подписывается на событие takeDamageEvent. Обработка события происходит в методе OnTakeDamage(int), в котором происходит уменьшение значения здоровья hitPoints:

public sealed class TakeDamageMechanics {     //Зависимости на данные:     private readonly AtomicEvent<int> takeDamageEvent;     private readonly AtomicVariable<int> hitPoints;      public TakeDamageMechanics(         AtomicEvent<int> takeDamageEvent,          AtomicVariable<int> hitPoints     )     {         this.takeDamageEvent = takeDamageEvent;         this.hitPoints = hitPoints;     }      //Аналогичен MonoBehaviour.OnEnable     public void OnEnable()     {         this.takeDamageEvent.Subscribe(this.OnTakeDamage);     }      //Аналогичен MonoBehaviour.OnDisable     public void OnDisable()     {         this.takeDamageEvent.Unsubscribe(this.OnTakeDamage);     }      //Логика:     private void OnTakeDamage(int damage)     {         this.hitPoints.Value -= damage;     } }

Теперь подключим TakeDamageMechanics к классу персонажа:

public sealed class Character : MonoBehaviour {     //Data:     public AtomicEvent<int> takeDamageEvent;     public AtomicVariable<int> hitPoints;          //Logic:     private TakeDamageMechanics takeDamageMechanics;      private void Awake()     {         this.takeDamageMechanics = new TakeDamageMechanics(             this.takeDamageEvent, this.hitPoints         );     }      private void OnEnable()     {         this.takeDamageMechanics.OnEnable();     }      private void OnDisable()     {         this.takeDamageMechanics.OnDisable();     } }

Подключим скрипт Character к GameObject’у на сцене

Подключение скрипта в Unity

Подключение скрипта в Unity

Теперь можно протестировать получение урона:

public sealed class TakeDamageTest : MonoBehaviour {     [SerializeField]     private Character character;      [SerializeField]     private int damage;      [ContextMenu("TakeDamage")]     public void TakeDamage()     {         this.character.takeDamageEvent.Invoke(this.damage);     } }

Теперь нужно сделать механику смерти персонажа, когда его здоровье равно нулю. Добавим данные и логику в класс Character:

public sealed class Character : MonoBehaviour {     //Data:     public AtomicEvent<int> takeDamageEvent;     public AtomicVariable<int> hitPoints;     public AtomicEvent deathEvent; //+     public AtomicVariable<bool> isDead; //+      //Logic:     private TakeDamageMechanics takeDamageMechanics;     private DeathMechanics deathMechanics; //+          private void Awake()     {         this.takeDamageMechanics = new TakeDamageMechanics(             this.takeDamageEvent, this.hitPoints         );         this.deathMechanics = new DeathMechanics(         //+             this.hitPoints, this.isDead, this.deathEvent  //+         );                                                //+     }          private void OnEnable()     {         this.takeDamageMechanics.OnEnable();         this.deathMechanics.OnEnable(); //+     }      private void OnDisable()     {         this.takeDamageMechanics.OnDisable();         this.deathMechanics.OnDisable(); //+     } }
  • Поле deathEvent — событие смерти

  • Поле isDead хранит состояние: мертв персонаж или нет

  • Поле deathMechanics обрабатывает событие смерти персонажа и ставит флажок isDead в true

Вот как выглядит механика смерти:

public sealed class DeathMechanics {     private readonly AtomicVariable<int> hitPoints;     private readonly AtomicVariable<bool> isDead;     private readonly AtomicEvent deathEvent;      public DeathMechanics(         AtomicVariable<int> hitPoints,          AtomicVariable<bool> isDead,          AtomicEvent deathEvent     )     {         this.hitPoints = hitPoints;         this.isDead = isDead;         this.deathEvent = deathEvent;     }      public void OnEnable()     {         this.hitPoints.Subscribe(this.OnHitPointsChanged);     }      public void OnDisable()     {         this.hitPoints.Unsubscribe(this.OnHitPointsChanged);     }      private void OnHitPointsChanged(int hitPoints)     {         if (hitPoints <= 0)         {             this.deathEvent.Invoke();             this.isDead.Value = true;         }     } }

Важно отметить, что когда описываем классы логики, то в этих классах не должно быть состояния! Эти классы только получают внешние зависимости на данные и работают с ними.

Механика перемещения

Теперь нужно заставить персонажа двигаться в различных направлениях, поэтому сделаем для него механику перемещения:

public sealed class MovementMechanics {     private readonly AtomicVariable<float> moveSpeed;     private readonly AtomicVariable<Vector3> moveDirection;     private readonly Transform transform;      public MovementMechanics(         AtomicVariable<float> moveSpeed,         AtomicVariable<Vector3> moveDirection,         Transform transform     )     {         this.moveSpeed = moveSpeed;         this.moveDirection = moveDirection;         this.transform = transform;     }      //Вызывается каждый кадр     public void Update()     {         this.transform.position += this.moveDirection.Value * (this.moveSpeed.Value * Time.deltaTime);     } }

Класс MoveMechanics принимает параметры скорости и направления перемещения и меняет позицию игрового объекта через Transform каждый кадр. Добавим механику в код персонажа:

public sealed class Character : MonoBehaviour {     //Data :     public AtomicVariable<float> moveSpeed;        //+     public AtomicVariable<Vector3> moveDirection;  //+      //Logic:     private MovementMechanics movementMechanics;   //+          private void Awake()     {         //+         this.movementMechanics = new MovementMechanics(             this.moveSpeed, this.moveDirection, this.transform         );     }      //+     private void Update()     {         this.movementMechanics.Update();     } }

Компилируем код и видим новые параметры для перемещения:

Инспектор в Unity

Инспектор в Unity

Если нужно протестировать перемещение, можно написать тестовый скрипт:

public sealed class MovementTest : MonoBehaviour {     [SerializeField]     private Character character;      private void Update()     {         if (Input.GetKey(KeyCode.LeftArrow))         {             this.character.moveDirection.Value = Vector3.left;         }         else if (Input.GetKey(KeyCode.RightArrow))         {             this.character.moveDirection.Value = Vector3.right;         }         else if (Input.GetKey(KeyCode.UpArrow))         {             this.character.moveDirection.Value = Vector3.forward;         }         else if (Input.GetKey(KeyCode.DownArrow))         {             this.character.moveDirection.Value = Vector3.back;         }         else         {             this.character.moveDirection.Value = Vector3.zero;         }     } }

Теперь нужно сделать так, чтобы персонаж не смог двигаться, если он мертв. Для этого напишем еще одну механику, которая будет разрешать / запрещать перемещение, исходя из состояния персонажа:

public sealed class CanMoveMechanics {     private readonly AtomicVariable<bool> canMove;     private readonly AtomicVariable<bool> isDead;      public CanMoveMechanics(       AtomicVariable<bool> canMove,        AtomicVariable<bool> isDead     )     {         this.canMove = canMove;         this.isDead = isDead;     }      //Вызывается каждый кадр     public void Update()     {         this.canMove.Value = this.CanMove();     }      private bool CanMove()     {         if (this.isDead.Value)         {             return false;         }          //В будущем можно добавить еще условия для перемещения         return true;     } }

Переменная canMove содержит информацию о том, может ли объект перемещаться или нет. Сделаем так, чтобы перемещение работало, если canMove находится в true:

//Доработаем механику перемещения: public sealed class MovementMechanics {     private readonly AtomicVariable<bool> canMove; //+     private readonly AtomicVariable<float> moveSpeed;     private readonly AtomicVariable<Vector3> moveDirection;     private readonly Transform transform;      public MovementMechanics(         AtomicVariable<bool> canMove, //+         AtomicVariable<float> moveSpeed,         AtomicVariable<Vector3> moveDirection,         Transform transform     )     {         this.canMove = canMove; //+         this.moveSpeed = moveSpeed;         this.moveDirection = moveDirection;         this.transform = transform;     }      public void Update()     {         if (this.canMove.Value) //+         {             this.transform.position += this.moveDirection.Value * (this.moveSpeed.Value * Time.deltaTime);         }     } }

Обновим скрипт Character.cs:

public sealed class Character : MonoBehaviour {     //Data :     public AtomicVariable<bool> canMove;           //+     public AtomicVariable<float> moveSpeed;             public AtomicVariable<Vector3> moveDirection;        //Logic:     private MovementMechanics movementMechanics;     private CanMoveMechanics canMoveMechanics; //+           private void Awake()     {         this.canMoveMechanics = new CanMoveMechanics( //+           this.canMove, this.isDead         );         this.movementMechanics = new MovementMechanics(           this.canMove, this.moveSpeed, this.moveDirection, this.transform //+         );     }      //+     private void Update()     {         this.movementMechanics.Update();         this.canMoveMechanics.Update(); //+     } }

Таким образом, мы получили персонажа, который умеет перемещаться, получать урон и умирать.

Реализация пули

Пуля должна иметь следующие механики: перемещаться в пространстве, наносить урон персонажу при попадании, и если пуля летит долго, то она исчезает со сцены.

Поскольку механику перемещения мы уже написали, то мы ее можем переиспользовать для пули:

public sealed class Bullet : MonoBehaviour {     //Data:     public AtomicVariable<bool> canMove = new(true);     public AtomicVariable<float> moveSpeed = new(3);     public AtomicVariable<Vector3> moveDirection = new(Vector3.forward);      //Logic:     private MovementMechanics movementMechanics;          private void Awake()     {         this.movementMechanics = new MovementMechanics(           this.canMove,            this.moveSpeed,            this.moveDirection,            this.transform       );     }      private void Update()     {         this.movementMechanics.Update();     } }

Механика времени жизни для пули будет выглядеть так:

public sealed class BulletLifetimeMechanics {     private readonly AtomicVariable<float> lifetime;     private readonly GameObject bullet;      public BulletLifetimeMechanics(         AtomicVariable<float> lifetime,          GameObject bullet     )     {         this.lifetime = lifetime;         this.bullet = bullet;     }      public void Update()     {         this.lifetime.Value -= Time.deltaTime;                  if (this.lifetime.Value <= 0)         {             GameObject.Destroy(this.bullet);         }     } }

Переменная lifetime будет хранить оставшееся время до уничтожения пули. Сама логика уменьшения времени будет обрабатываться в методе Update() через Time.deltaTime. В классах я стараюсь избегать корутин. Подключаем поведение к пуле:

public sealed class Bullet : MonoBehaviour {     //Data:     public AtomicVariable<float> lifetime = new(3); //+      //Logic:     private BulletLifetimeMechanics lifetimeMechanics; //+          private void Awake()     {         //+         this.lifetimeMechanics = new BulletLifetimeMechanics(           this.lifetime, this.gameObject         );     }      private void Update()     {        this.lifetimeMechanics.Update(); //+     } }

Теперь механика нанесения урона персонажу при столкновении с ним:

public sealed class BulletCollisionMechanics {     private readonly AtomicVariable<int> damage;     private readonly GameObject bullet;      public BulletCollisionMechanics(       AtomicVariable<int> damage,        GameObject bullet     )     {         this.damage = damage;         this.bullet = bullet;     }      public void OnTriggerEnter(Collider collider)     {         if (collider.TryGetComponent(out Character character))         {             character.takeDamageEvent.Invoke(this.damage.Value);             GameObject.Destroy(this.bullet);         }     } }

Метод OnTriggerEnter делегирует вызов из монобеха и сам обрабатывает логику столкновения.

В результате класс Bullet будет выглядеть следующим образом:

public sealed class Bullet : MonoBehaviour {     public AtomicVariable<bool> canMove = new(true);     public AtomicVariable<float> moveSpeed = new(3);     public AtomicVariable<Vector3> moveDirection = new(Vector3.forward);      [Space]     public AtomicVariable<int> damage = new(1);     public AtomicVariable<float> lifetime = new(3);      private MovementMechanics movementMechanics;     private BulletCollisionMechanics collisionMechanics;     private BulletLifetimeMechanics lifetimeMechanics;      private void Awake()     {         this.movementMechanics = new MovementMechanics(           this.canMove, this.moveSpeed, this.moveDirection, this.transform         );         this.collisionMechanics = new BulletCollisionMechanics(           this.damage, this.gameObject         );         this.lifetimeMechanics = new BulletLifetimeMechanics(           this.lifetime, this.gameObject         );     }      private void Update()     {         this.movementMechanics.Update();         this.lifetimeMechanics.Update();     }      private void OnTriggerEnter(Collider other)     {         this.collisionMechanics.OnTriggerEnter(other);     } }

Если добавить коллайдеры на персонажа и пулю, то пуля будет лететь и наносить урон

Инспектор в Unity

Инспектор в Unity

Выводы

Итак, что нам дает подход разделения данных и логики:

  1. Каждая механика становится универсальной, ее можно переиспользовать

  2. Легко расширять и изменять структуру игровых объектов

  3. Легко модифицировать механики, поскольку любая механика может принимать любой набор данных

Какие рекомендации, я хотел бы дать:

  1. Стараться делать так, чтобы класс логики не имел состояния

  2. Стараться делать классы логики по принципу SRP

  3. Стараться уходить от корутин в целях производительности

В завершении скажу, что это был один из способов атомарного подхода реализации игровых объектов, а подробнее про атомарные механики на Unity, я расскажу на бесплатном вебинаре уже 5 октября. Благодарю за внимание и жду всех на вебинаре 🙂

Ссылка на демо-проект в Google Drive


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