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

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

Цель статьи — наглядно показать, как можно гибко описывать и переиспользовать поведение игровых объектов в 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

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

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

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

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

Выводы

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

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

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

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

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

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

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

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

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

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