November 12, 2023

Введение в атомарный подход

Всем привет! Сегодня хотел бы поделиться основами атомарного подхода, с помощью которого можно собирать игровые объекты как конструктор.

Если вкратце, атомарный подход — это способ организации кода игрового объекта, структура которого состоит из атомарных элементов данных и логики, которые обрабатывают эти данные.

Ничего не поняли? Давайте приведу пример персонажа из RPG игры.

С точки зрения кода любой игровой объект состоит из данных и логики. Любой параметр здоровья, уровня или скорости перемещения является элементом данных. А любая механика перемещения или атаки является элементом логики. Таким образом, игровой объект является композицией данных и логики.

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

Конкретный пример

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

У персонажа будет здоровье и возможность перемещаться, а у пули — лететь и наносить урон при попадании в персонажа. Давайте запилим на атомарном подходе.

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

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

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();
    }
}

Теперь персонаж будет получать урон, если вызвать у него событие takeDamageEvent:

public sealed class TakeDamageTest : MonoBehaviour
{
    [SerializeField]
    private Character character;

    [SerializeField]
    private int damage;

    [ContextMenu("TakeDamage")]
    public void TakeDamage()
    {
        this.character.takeDamageEvent.Invoke(this.damage);
    }
}

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

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 AtomicEvent<int> takeDamageEvent;
    public AtomicVariable<int> hitPoints;
    
    public AtomicVariable<float> moveSpeed;        //+
    public AtomicVariable<Vector3> moveDirection;  //+

    //Logic:
    private TakeDamageMechanics takeDamageMechanics;
    private MovementMechanics movementMechanics;   //+
    
    private void Awake()
    {
        this.takeDamageMechanics = new TakeDamageMechanics(
            this.takeDamageEvent, this.hitPoints
        );    

        //+
        this.movementMechanics = new MovementMechanics(
            this.moveSpeed, this.moveDirection, this.transform
        );
    }

    //+
    private void Update()
    {
        this.movementMechanics.Update();
    }
    
    private void OnEnable()
    {
        this.takeDamageMechanics.OnEnable();
    }

    private void OnDisable()
    {
        this.takeDamageMechanics.OnDisable();
    }
}

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

public sealed class MovementController : 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;
        }
    }
}

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

Пуля должна иметь следующие механики:

  1. Перемещаться в пространстве
  2. Наносить урон персонажу при попадании

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

public sealed class Bullet : MonoBehaviour
{
    //Data:
    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.moveSpeed, 
          this.moveDirection, 
          this.transform
      );
    }

    private void Update()
    {
        this.movementMechanics.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<float> moveSpeed = new(3);
    public AtomicVariable<Vector3> moveDirection = new(Vector3.forward);
    public AtomicVariable<int> damage = new(1);

    //Логика
    private MovementMechanics movementMechanics;
    private BulletCollisionMechanics collisionMechanics;

    private void Awake()
    {
        this.movementMechanics = new MovementMechanics(
          this.canMove, this.moveSpeed, this.moveDirection, this.transform
        );
        this.collisionMechanics = new BulletCollisionMechanics(
          this.damage, this.gameObject
        );
    }

    private void Update()
    {
        this.movementMechanics.Update();
        this.lifetimeMechanics.Update();
    }

    private void OnTriggerEnter(Collider other)
    {
        this.collisionMechanics.OnTriggerEnter(other);
    }
}

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

Выводы

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

  • Во-первых, благодаря подходу разделения данных и логики большинство игровых механик будут универсальными, а значит из можно будет реюзать. За счет переиспользования механик, будет меньше дублирования кода, следовательно, код-база будет расти медленнее, и с проектом работать будет проще.
  • Во-вторых, сам подход позволяет описать структуру игрового объекта, которую легко читать, поддерживать и расширять.
  • В-третьих, подход уменьшает кол-во использования класса MonoBehaviour, следовательно, кол-во памяти будет расходоваться меньше, а производительность будет выше, поскольку апдейты будут вызываться не из нативного кода.


На этом вводная часть атомарного подхода закончена.

Спасибо за внимание :)