Как стать автором
Поиск
Написать публикацию
Обновить
506.49
OTUS
Развиваем технологии, обучая их создателей

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

Уровень сложностиПростой
Время на прочтение9 мин
Количество просмотров8.2K

Привет, Хабр! Меня зовут Игорь, и я 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

Теги:
Хабы:
Всего голосов 10: ↑8 и ↓2+8
Комментарии18

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS