Как стать автором
Обновить

Атомарный подход в Unity

Уровень сложностиСредний
Время на прочтение29 мин
Количество просмотров6.8K

Привет, Хабр! 👋

Меня зовут Игорь, и я Unity Developer. Последние несколько месяцев я разрабатываю зомби шутер в Unity на атомарном подходе. Несмотря на то, что подход оказался достаточно удобным и гибким, я столкнулся я рядом архитектурных проблем в процессе разработки. Поэтому в этой статье я хотел бы обновить концепцию атомарного подхода: еще раз объяснить, что это такое, и показать, как это работает. В конце подведу итоги, разберем преимущества и недостатки.

Содержание

Что такое атомарный подход

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

Если говорить вкратце, то ядро — это "внутренний мир" объекта, а оболочка — "внешний".

Давайте более конкретно рассмотрим ядро объекта на примере персонажа:

Ядро — композиция данных и логики
Ядро — композиция данных и логики

С точки зрения кода любой игровой объект состоит из данных и логики. Любой параметр здоровья или скорости является элементом данных, а любой алгоритм перемещения или получения урона — элементом логики. Таким образом, игровой объект является композицией данных и логики, и эта композиция называется ядро объекта. Поскольку в реализации ядра присутствует принцип разделения данных и логики, то это позволяет повторно использовать механики между разными объектами, что уменьшает рост код-базы и делает разработку более эффективной. В общем, еще раз скажу: ядро отвечает за наполнение игрового объекта элементами данных и логики.

Оболочка отвечает за взаимодействие игрового объекта с внешним миром. Она является своего рода публичным интерфейсом, через который можно взаимодействовать с объектом: "говорить", что он должен делать, и получать информацию о его состоянии. Самое важное здесь — это то, что оболочка универсальна для всех игровых объектов, поэтому у разработчика есть возможность обрабатывать игровые объекты разных типов, а также подменять одну реализацию объекта на другую, не переписывая код системы. Благодаря оболочке соблюдается ряд принципов: инкапсуляция, абстракция, полиморфизм, принцип открытости-закрытости и принцип инверсии зависимости.

Взаимодействие с объектом всегда идет через оболочку
Взаимодействие с объектом всегда идет через оболочку

В результате модель игрового объекта выглядит в виде модели атома, но есть ряд нюансов, которые нужно учитывать:

Структура игрового объекта
Структура игрового объекта
  1. Атомарные элементы могут одновременно относиться и к данным и к логике, и это нормально. Например, условие атаки может выступать в роли логики, так как в классе условия прописывается алгоритм проверки, но, с другой стороны, условие может выступать как данные, поскольку на это условие можно получить ссылку и вызвать его.

  2. Также элементами данных будут различные компоненты Unity, такие как Animator, Collider или Rigidbody, другие монобехи и Plain C# классы, которые тоже выступают в роли данных.

  3. Набор данных и логики игрового объекта может меняться в процессе выполнения программы. Например, в процессе игры у персонажа может меняться поведение атаки, исходя из того, какое оружие у него в руках. Также у персонажа могут добавляться новые статы или способности. Поэтому атомарный подход учитывает и эти ситуации.

Пример на Unity

Давайте теперь разберем более конкретно, как эта концепция работает в Unity на примере персонажа. Предположим, что есть игровой объект персонажа, который имеет здоровье, умеет получать урон и перемещаться. Код персонажа на атомарном подходе будет выглядеть так:

[Is("Unit", "TakeDamagable", "Moveable")]
public sealed class Character : AtomicObject
{
    //Данные:
    [Get("TakeDamage")]
    public AtomicEvent<int> takeDamageEvent = new();

    [Get("HitPoints")]
    public AtomicVariable<int> hitPoints = new(10);

    [Get("MoveDirection")]
    public AtomicVariable<Vector3> moveDirection = new(Vector.zero);

    [Get("MoveSpeed")]
    public AtomicVariable<float> moveSpeed = new(5.0f);
    
    //Логика:
    private TakeDamageMechanics takeDamageMechanics;

    private MovementMechanics movementMechanics;

    //Конструктор:
    public override void Compose()
    {
        base.Compose();
        
        this.takeDamageMechanics = new TakeDamageMechanics(
            this.takeDamageEvent, this.hitPoints
        );

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

    //Unity методы:
    private void Awake()
    {
        this.Compose(); //Вызвать конструктор атомарного объекта
    }

    private void OnEnable()
    {
        this.takeDamageMechanics.OnEnable(); //Вкл механику получения урона
    }

    private void OnDisable()
    {
        this.takeDamageMechanics.OnDisable(); //Выкл механику получения урона
    }

    private void FixedUpdate()
    {
        this.movementMechanics.FixedUpdate(); //Вызвать механику перемещения
    }
}

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

Теперь разберем более подробно синтаксис кода:

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

    [SerializeField]
    private AtomicObject character; //Работаем через базовый класс
  • Во-вторых, над классом Character стоит атрибут [Is]. Этот атрибут указывает к каким типам принадлежит модель нашего персонажа. В конкретном примере персонаж имеет типы: Unit, TakeDamagable, Moveable. Это означает, что наш персонаж является юнитом, может получать урон и перемещаться. Вызывая метод AtomicObject.Is(string), разработчик может проверить, относится ли объект к данному типу или нет.

    character.Is("Moveable"); //Возвращает bool
  • В-третьих, над некоторыми полями класса есть атрибуты [Get]. Этот атрибут определяет "публичные" поля объекта. Через метод AtomicObject.Get<T>(string) можно получить значение поля, помеченного этим атрибутом. Например, если мы захотим получить кол-во здоровья нашего персонажа, то это будет выглядеть так:

    var hitPoints = character.Get<AtomicVariable<int>>("HitPoints");

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

//Метод нанесения урона персонажу:
void DealDamage(AtomicObject character, int damage)
{
    if (character.Is("TakeDamagable"))
    {
        var takeDamageEvent = character.Get<AtomicEvent<int>>("TakeDamage");
        takeDamageEvent.Invoke(damage);
    }
}

//Метод изменения направления движения
void MoveTowards(AtomicObject character, Vector3 direction)
{
    if (character.Is("Moveable"))
    {
        var characterDirection = character
          .Get<AtomicVariable<Vector3>>("MoveDirection");
      
        characterDirection.Value = direction; 
    }
}

Теперь давайте разберем ядро персонажа. Сам по себе класс Character описан декларативно, нем нет бизнес-логики и алгоритмов, только композиция + конфигурация. В данном примере ядро персонажа состоит из четырех элементов данных и двух элементов логики:

    //Данные:
    public AtomicEvent<int> takeDamageEvent = new();
    public AtomicVariable<int> hitPoints = new (10);
    public AtomicVariable<Vector3> moveDirection = new(Vector.zero);
    public AtomicVariable<float> moveSpeed = new(5.0f);
    
    //Логика:
    private TakeDamageMechanics takeDamageMechanics;
    private MovementMechanics movementMechanics;
  • Данные:

    • Поле takeDamageEvent является событием получения урона;

    • Поле hitPoints содержит текущее кол-во здоровья;

    • Поле moveDirection содержит текущее направление движения;

    • Поле moveSpeed содержит текущую скорость перемещения.

  • Логика:

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

    • Поле movementMechanics перемещает transform персонажа с заданной скоростью и направлением каждый кадр.

  • Метод Compose() является конструктором атомарного объекта, в котором создаются и настраиваются экземпляры механик. Его нужно всегда вызывать вручную!

  public override void Compose()
  {
      base.Compose();
      
      this.takeDamageMechanics = new TakeDamageMechanics(
          this.takeDamageEvent, this.hitPoints
      );
    
      this.movementMechanics = new MovementMechanics(
          this.moveSpeed, this.moveDirection, this.transform
      );
  }
  • Дальше определяем, когда нужно вызывать конструктор и механики. В конкретном примере вызов будет происходить по событиям Unity:

  private void Awake()
  {
      this.Compose(); //Конструктор атомарного объекта
  }
  
  private void OnEnable()
  {
      this.takeDamageMechanics.OnEnable(); //Вкл механику получения урона
  }
  
  private void OnDisable()
  {
      this.takeDamageMechanics.OnDisable(); //Выкл механику получения урона
  }
  
  private void FixedUpdate()
  {
      this.movementMechanics.FixedUpdate(); //Вызываем механику перемещения
  }

Теперь давайте разберем, что такое AtomicVariable<T>, AtomicEvent<T>.

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

[Serializable]
public sealed class AtomicEvent<T> : IAtomicEvent
{
    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> : IAtomicVariable<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;
    }
}

Важно отметить, что такие структуры разработаны специально для того, чтобы:

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

б.) можно было подменять одни структуры данных на другие через полиморфизм.

Дополнение: Поскольку в C# работа с указателями ограничена, то указатели сделаны в виде классов. Благодаря использованию ссылочных структур данных в программе не происходит боксинга / анбоксинга, а значит не выделяется дополнительная память в куче. Это особенно важно, поскольку метод AtomicObject.Get<T>() будет использоваться часто.

Теперь давайте рассмотрим классы-логики TakeDamageMechanics и MoveMechanics:

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

public sealed class TakeDamageMechanics
{
    //Зависимости на данные:
    private readonly IAtomicEvent<int> takeDamageEvent;
    private readonly IAtomicVariable<int> hitPoints;

    public TakeDamageMechanics(
        IAtomicEvent<int> takeDamageEvent, 
        IAtomicVariable<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;
    }
}

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

public sealed class MovementMechanics
{
    private readonly IAtomicValue<float> moveSpeed;
    private readonly IAtomicValue<Vector3> moveDirection;
    private readonly Transform transform;

    public MovementMechanics(
        IAtomicValue<float> moveSpeed,
        IAtomicValue<Vector3> moveDirection,
        Transform transform
    )
    {
        this.moveSpeed = moveSpeed;
        this.moveDirection = moveDirection;
        this.transform = transform;
    }

    //Вызывается каждый кадр
    public void FixedUpdate()
    {
        this.transform.position += this.moveDirection.Value *
                      (this.moveSpeed.Value * Time.deltaTime);
    }
}

Здесь важно отметить, что классы логики не имеют своего состояния, они взаимодействуют друг с другом через данные (как System'ы в ECS). Поэтому механики просто получают необходимые зависимости и работают с ними.

Обратите внимание, что зависимости в конструктор передаются через интерфейс. Это нужно, чтобы можно было подменять структуры данных так, чтобы механики были более универсальными и переиспользуемыми.

Выше я говорил, что в основе разработки игровых механик лежит принцип разделения данных и логики. Такой подход позволяет реализовать следующую штуку: если я захочу сделать такую же механику перемещения для пули, я могу просто подключить MovementMechanics в класс Bullet:

public sealed class Bullet : AtomicObject
{
    //Data:
    public AtomicVariable<float> moveSpeed = new(3);
    public AtomicFunction<Vector3> moveDirection;

    //Logic:
    private MovementMechanics movementMechanics;

    public override void Compose ()
    {
        base.Compose();

        this.moveDirection = new AtomicFunction<Vector3>(
          () => this.transform.forward
        );
        
        this.movementMechanics = new MovementMechanics(
          this.moveSpeed, 
          this.moveDirection, 
          this.transform
      );
    }
    
    private void Awake()
    {
       this.Compose();
    }

    private void FixedUpdate()
    {
        this.movementMechanics.FixedUpdate();
    }
}

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

Пример переиспользования "нанесения урона" в проекте
Пример переиспользования "нанесения урона" в проекте

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

Атомарные структуры данных

Если говорить вкратце, то мой пятилетний опыт разработки игр подводит меня к тому, что 75% игровых механик состоят из примитивных структур данных: константы, переменные, функции, события, действия, механики.

Атомарные структуры данных
Атомарные структуры данных

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

  Константа: IAtomicValue<T>, AtomicValue<T> 
  Переменная: IAtomicVariable<T>, AtomicVariable<T>
  Функция: IAtomicFunction<T>, AtomicFunction<T>
  Событие: IAtomicEvent<T>, AtomicEvent
  Действие: IAtomicAction<T>, AtomicAction
  • AtomicValue используется, когда нужно получить значение только на чтение.

public sealed class MovementMechanics
{
    //Ссылки на read-only значения:
    private readonly IAtomicValue<float> moveSpeed;
    private readonly IAtomicValue<Vector3> moveDirection;

    ...
  
    public void FixedUpdate()
    {
        this.transform.position += this.moveDirection.Value *
                      (this.moveSpeed.Value * Time.deltaTime); 
     
    }
}
  • AtomicVariable используется, когда нужно изменить значение.

public sealed class TakeDamageMechanics
{
    //Ссылка на переменную:
    private readonly IAtomicVariable<int> hitPoints;

    ...
    
    private void OnTakeDamage(int damage)
    {
        this.hitPoints.Value -= damage;
    }
}
  • AtomicFunction<T> используется, когда нужно получить значение через функцию. Такие функции очень удобно подкладывать в механики вместо констант:

IAtomicValue<float> attackRadius = new AtomicFunction<float>(
  () => this.config.attackRadius;
)

IAtomicValue<Vector3> forwardDirection = new AtomicFunction<Vector3>(
  () => this.transform.forward
);
  • AtomicEvent<T> используется, если нужно обработать событие.

public sealed class TakeDamageMechanics
{
    //Ссылка на событие:
    private readonly IAtomicEvent<int> takeDamageEvent;

    ...
  
    public void OnEnable()
    {
        this.takeDamageEvent.Subscribe(this.OnTakeDamage);
    }

    public void OnDisable()
    {
        this.takeDamageEvent.Unsubscribe(this.OnTakeDamage);
    }

    private void OnTakeDamage(int damage) {...}    
}
  • AtomicAction<T> используется, если нужно выполнить действие в игре.

//Действие нанесения урона:
public sealed class DealDamageAction : IAtomicAction<IAtomicObject> 
{
    private IAtomicValue<int> damage;
  
    public void Compose(IAtomicValue<int> damage) 
    {
        this.damage = damage;
    }
  
    public void Invoke(IAtomicObject target)
    {
        if (target.Is("TakeDamagable")) 
        {
            var takeDamageAction = target.Get<IAtomicAction<int>>("TakeDamage");
            takeDamageAction.Invoke(damage.Value);
        }
    }
}
  • Механики — это кастомные элементы логики. Обычно механики вызываются каждый кадр, или обрабатывают коллизии, или являются обработчиками событий. В целом мы с вами уже рассмотрели механики получения урона и атаки в примерах, которые были выше.

На самом деле в моем проекте есть целая библиотека таких атомарных структур, которыми я пользуюсь для описания игровых объектов.

Библиотека Elements для атомарного подхода
Библиотека Elements для атомарного подхода

Хочу отметить, что вместо Atomic классов можно использовать и библиотеку UniRx с реактивными свойствами и коллекциями. Самая главное — это организовать удобные структуры для работы с данными и возможность подменять их реализации, в различных ситуациях.

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

 Кусочек кода, демонстрирующий использование, атомарных структур
Кусочек кода, демонстрирующий использование, атомарных структур

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

//Этот класс действия можно переиспользовать в проекте, 
//поскольку ссылку на позицию и скорость можно реализовать разными способами

public sealed class MoveTowardsAction : IAtomicAction<Vector3>
{
    private IAtomicVariable<Vector3> currentPosition;
    private IAtomicValue<float> moveSpeed;

    public void Compose(
        IAtomicVariable<Vector3> currentPosition,
        IAtomicValue<float> moveSpeed
    )
    {
        this.currentPosition = currentPosition;
        this.moveSpeed = moveSpeed;
    }

    public void Invoke(Vector3 direction)
    {
        this.currentPosition.Value += direction * this.moveSpeed.Value;
    }
}

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

//Пример магазина для оружия в зомби шутере:

[Serializable]
public sealed class WeaponMagazine
{
    public event Action OnStateChanged;

    [SerializeField, Min(0)]
    private int current;

    [SerializeField, Min(0)]
    private int max;

    public int Current => this.current;

    public int Max
    {
        get => this.max;
        set => this.max = value;
    }

    public bool IsFull()
    {
        return this.current >= this.max;
    }

    public bool IsNotFull()
    {
        return this.current < this.max;
    }

    public bool IsEmpty()
    {
        return this.current == 0;
    }

    public bool IsNotEmpty()
    {
        return this.current > 0;
    }
    
    public int GetFreeSlots()
    {
        return this.max - this.current;
    }

    public void SpendCharge()
    {
        if (this.current <= 0)
        {
            throw new Exception("Can't spend bullet!");
        }

        this.current--;
        this.OnStateChanged?.Invoke();
    }

    public void AddCharges(int range)
    {
        this.current = Mathf.Min(this.current + range, this.max);
        this.OnStateChanged?.Invoke();
    }

    public void SetFull()
    {
        this.current = this.max;
        this.OnStateChanged?.Invoke();
    }

    public float GetProgress()
    {
        return (float) this.current / this.max;
    }
}

Секции и компоненты

Когда речь заходит о разработке более сложных игровых объектов с большим кол-вом свойств, событий и механик, то структуру таких объектов становится сложно читать и поддерживать. Нарушается принцип единственной ответственности. Поэтому для того, чтобы проще было ориентироваться в структуре игрового объекта и переиспользовать его части, его нужно "подразбить" на секции и компоненты.

Секция — это слой игрового объекта, который содержит в себе данные и логику и выполняет глобальную ответственность. Слои могут быть разные, но основном у объекта есть два слоя: Core и View.

Слой Core отвечает за логико-математическую модель объекта, а слой View — за его представление, то есть как игровой объект будет отображаться и звучать в игре. Такое разделение сразу улучает читаемость структуры игрового объекта и упрощает его поддержку.

Для лучшего понимания рассмотрим слои пули, которую я взял из проекта:

public sealed class Bullet : AtomicObject
{
    [Section]
    public Bullet_Core core;

    [Section]
    public Bullet_View view;

    private void FixedUpdate()
    {
        this.core.FixedUpdate();
    }

    private void OnCollisionEnter(Collision collision)
    {
        this.core.OnCollisionEnter(collision);
    }
}

Чтобы сделать секцию, достаточно поставить атрибут [Section] над полем класса.

//Секция модели пули

[Serializable]
public sealed class Bullet_Core
{
    public AtomicVariable<float> lifetime = new(3);
    public AtomicValue<int> damage = new(1);
    public AtomicValue<float> speed = new(15);

    [Get("IsAlive")]
    public AtomicVariable<bool> isAlive = new(true);
    public AtomicEvent deathEvent;

    [Get("Team")]
    public AtomicVariable<TeamType> team;

    //Actions:
    public IsEnemyFunction damageCondition;
    public DealDamageAction damageAction;
    public AtomicEvent respawnEvent;

    //Mechanics:
    private ForwardMovementMechanics movementMechanics;
    private LifetimeMechanics lifetimeMechanics;
    private BulletCollisionMechanics collisionMechanics;

    [Compose]
    private void Compose(Bullet bullet) //Конструктор для секции
    {
        this.ComposeActions(bullet);
        this.ComposeEvent(bullet.gameObject);
        this.ComposeMechanics(bullet);
    }

    private void ComposeEvent(GameObject go)
    {
        this.respawnEvent.Subscribe(() =>
        {
            gameObject.SetActive(true);
            this.lifetime.Value = 3;
            this.isAlive.Value = true;
        });
    }

    private void ComposeActions(Bullet bullet)
    {
        this.damageCondition.Compose(this.team);
        this.damageAction.Compose(this.damage, bullet);
    }

    private void ComposeMechanics(Bullet bullet)
    {
        this.movementMechanics = new ForwardMovementMechanics(
            bullet.transform, this.speed
        );
        this.collisionMechanics = new BulletCollisionMechanics(
            this.damageCondition, this.damageAction, this.isAlive, this.deathEvent, this.lifetime
        );
        this.lifetimeMechanics = new LifetimeMechanics(
            this.lifetime, this.isAlive, this.deathEvent
        );
    }

    public void FixedUpdate()
    {
        this.movementMechanics.FixedTick();
        this.lifetimeMechanics.FixedTick();
    }

    public void OnCollisionEnter(Collision collision)
    {
        this.collisionMechanics.OnCollisionEnter(collision);
    }
}
//Секция отображения пули

[Serializable]
public sealed class Bullet_View
{
    [SerializeField]
    private TrailVFXEmitter trailEmitter;

    [SerializeField]
    private Transform trailContainer;
    
    private TrailVFX myTrail;

    [Compose]
    private void Compose(Bullet_Core core)
    {
        core.respawnEvent.Subscribe(this.OnAlive);
        core.deathEvent.Subscribe(this.OnDeath);
    }

    private void OnAlive()
    {
        this.myTrail = this.trailEmitter.Emit(this.trailContainer);
    }

    private void OnDeath()
    {
        this.trailEmitter.Stop(this.myTrail);
    }
}

В каждой секции можно сделать конструктор, который будет вызываться автоматически вместе с методом Compose() базового класса AtomicObject. Чтобы его добавить, достаточно просто написать метод с атрибутом [Compose] и передать ему зависимости на другие секции или класс базового объекта, если это нужно. Атомарный фреймворк сам сделает внедрение зависимостей в каждую секцию и вызовет метод Compose(). Также внутри разделов поддерживается возможность добавлять атрибуты [Get] над полями, и фреймворк тоже соберет это и положит в базовый класс AtomicObject.

Также хорошим тоном будет сделать и конфиг в виде секции, чтобы его тоже можно было передавать в качестве аргумента в каждую секцию:

public sealed class BulletConfig : ScriptableObject
{
    public int damage;
    public float speed;
    public float lifetime;
}

public sealed class Bullet : AtomicObject
{
    [Section]
    public BulletConfig config; 

    [Section]
    public Bullet_Core core;

    [Section]
    public Bullet_View view;

    ...
}

[Serializable]
public sealed class Bullet_Core 
{
    [Compose]
    private void Compose(Bullet bullet, BulletConfig config) 
    {
        this.movementMechanics = new ForwardMovementMechanics(
            bullet.transform, new AtomicValue<float>(config.speed)
        );
      
         this.dealDamageAction.Compose(
            new AtomicFunction<int>(() => config.damage)
            bullet
        );  
    }
}

Тут же покажу, какие секции есть у персонажа в проекте:

[Is("Unit", "Character")]
public sealed class Character : AtomicObject
{
    [Section]
    public CharacterConfig config;

    [Section]
    public Character_Core core;

    [Section]
    public Character_Anim anim;

    [Section]
    public Character_IK ik;

    [Section]
    public Character_View view;

    [Section]
    public Character_UI ui;

    ...
}

Еще есть возможность организовать иерархию секций, то есть делать секции внутри других секций. Раньше я так делал, но сейчас я отказался от такого подхода в пользу компонентов, о чем расскажу дальше.

Еще хотел бы отметить, что передавать секции в механики или другие объекты — это плохая практика, так как механики жестко завязываются на секции, и их потом нельзя переиспользовать!

❌ Неправильно передавать секции
public HitPointsEmptyMechanics(Character_Core core)
{
    this.hitPoints = core.hitPoints;
    this.deathEvent = core.deathEvent;
}

✅ Правильно передавать данные
public HitPointsEmptyMechanics(HitPoints hitPoints, IAtomicEvent deathEvent)
{
    this.hitPoints = hitPoints;
    this.deathEvent = deathEvent;
}

Теперь поговорим, что такое компоненты.

Компонент — это та же самая секция, только маленькая. Компонент также хранит в себе данные и логику, но решает одну локальную задачу. При этом компоненты в отличие от секций подразумевают переиспользуемость между объектами. Поэтому желательно стараться проектировать компоненты так, чтобы они были более универсальными.

//Пример компонента жизни из проекта:

[Is("Healthable" "TakeDamagable"), Serializable]
public sealed class LifeComponent : IInitializable, IDisposable
{
    [Get("HitPoints")]
    public HitPoints hitPoints;

    [Get("IsAlive")]
    public AtomicFunction<bool> isAlive;

    [Get("IsDead")]
    public AtomicFunction<bool> isDead;

    [Get("IsFull")]
    public AtomicFunction<bool> isFull;

    [Space]
    [Get("TakeDamageAction")]
    public TakeDamageAction takeDamageAction;

    [Get("TakeDamageEvent")]
    public AtomicEvent<TakeDamageArgs> takeDamageEvent;

    [Space]
    [Get("DeathEvent")]
    public AtomicEvent deathEvent;
    
    [Space]
    [Get("RestoreHealthAction"]
    public RestoreHitPointsAction restoreAction;
    
    [Get("RestoreHealthEvent")]
    public AtomicEvent<int> restoreHitPointsEvent;

    private HitPointsEmptyMechanics deathMechanics;

    public void Compose()
    {
        this.isAlive.Compose(() => this.hitPoints.IsExists);
        this.isDead.Compose(() => !this.hitPoints.IsExists);
        this.isFull.Compose(() => this.hitPoints.IsFull);
        
        this.restoreAction.Compose(this.hitPoints, this.restoreHitPointsEvent);
        this.takeDamageAction.Compose(this.hitPoints, this.takeDamageEvent);
        
        this.deathMechanics = new HitPointsEmptyMechanics(this.hitPoints, this.deathEvent);
    }

    public void Initialize()
    {
        this.deathMechanics.Initialize();
    }

    public void Dispose()
    {
        this.deathMechanics.Dispose();
    }
}
//Пример использования компонентов в игровых объектах

[Serializable]
public sealed class Character_Core
{
    [Section]
    public TransformComponent transformComponent;

    [Section]
    public LifeComponent lifeComponent;

    [Section]
    public MoveComponent moveComponent;

    [Section]
    public TeamComponent teamComponent;

    [Section]
    public CharacterWeaponComponent weaponComponent;

    ...
  }


[Serializable]
public sealed class Zombie_Core
{
    [Section]
    public TransformComponent transformComponent;

    [Section]
    public LifeComponent lifeComponent;

    [Section]
    public MoveComponent moveComponent;

    [Section]
    public TeamComponent teamComponent;

    ...
  }

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

[Serializable]
public sealed class CharacterWeaponComponent : IInitializable, IDisposable
{
    public AtomicVariable<Weapon> currentWeapon;
    public IsMeleeWeaponFunction isMeleeCurrentWeapon;
    public AtomicFunction<bool> isCurrentWeaponReady;
    
    public WeaponsStorage weaponStorage;
    public WeaponBehaviour weaponBehaviour;

    public SwitchWeaponAction switchWeaponAction;
    public TryPickUpWeaponAction pickUpWeaponAction;
    public AtomicEvent<IWeaponItem, bool> pickUpWeaponEvent;
    
    private PickUpWeaponMechanics pickUpMechanics;
    private SelectNextWeaponWhenPreviousEndedMechanics autoSwitchMechanics;

    public void Compose(AtomicObject owner, IAtomicVariable<bool> attackState, TriggerDispatcher trigger)
    {
        this.isMeleeCurrentWeapon.Compose(this.currentWeapon);

        this.isCurrentWeaponReady.Compose(() =>
        {
            var weapon = this.currentWeapon.Value;
            return weapon == null || weapon.CanFire.Value;
        });
        
        this.pickUpWeaponAction.Compose(this.weaponsStorage, this.currentWeapon);
        this.switchWeaponAction.Compose(this.weaponsStorage, this.currentWeapon, attackState);
        
        this.pickUpMechanics = new PickUpWeaponMechanics(
            trigger, this.pickUpWeaponAction, this.pickUpWeaponEvent
        );
        this.autoSwitchMechanics = new SelectNextWeaponWhenPreviousEndedMechanics(
            this.currentWeapon, this.weaponsStorage, this.switchWeaponAction
        );
        
        this.weaponStorage.Compose(owner);
        this.weaponBehaviour.Compose(owner, this.currentWeapon);
    }

    public void Initialize()
    {
        this.pickUpMechanics.Initialize();
        this.autoSwitchMechanics.Initialize();
    }

    public void Dispose()
    {
        this.pickUpMechanics.Dispose();
        this.autoSwitchMechanics.Dispose();
    }
}

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

//Атомарный объект, который имеет у себя компонент LifeComponent
//автоматически является Healthable & TakeDamagable, и у него
//можно получить данные здоровья через ключ HitPoints

[Is("Healthable", "TakeDamagable")] 
public sealed class LifeComponent : IInitializable, IDisposable
{
    [Get("HitPoints")]
    public HitPoints hitPoints;

    [Get("IsAlive")]
    public AtomicFunction<bool> isAlive;
    
    ...
}

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

Уровни организации игрового объекта
Уровни организации игрового объекта

В первую очередь необходимо выделить ключевые слои объекта, в основном это секции Core и View, затем каждую секцию наполнить компонентами, а компоненты собрать из логики и данных. Понятно, что в реальности будет так, что и в секциях будут атомарные элементы, и это нормально.

Структура зомби в инспекторе
Структура зомби в инспекторе

Хочу отметить, что не нужно городить секции и компоненты, если у вас простой объект, типа разрушаемого пропса. Помним про принцип KISS:

public sealed class DestroyableProp : AtomicObject
{
    [Get("Destroy")]
    public AtomicAction destroyAction;

    public ParticleSystem vfx;

    private void Awake()
    {
        this.Compose();
    }

    public override void Compose()
    {
        base.Compose();
        
        this.destroyAction.Compose(() => {
            this.vfx.Play();
            Destroy(this.gameObject)
        });
    }
}

Вот так можно работать со сложными объектами.

Абстракция объекта

Когда мы разбирали основы атомарного подхода на примере класса Character, мы говорили о том, что все взаимодействия с игровыми объектами должны быть через базовый класс AtomicObject вместо Character. И этот выбор действительно оправдан тем, что всегда есть возможность подменить одну реализацию объекта на другой, не переписывая код взаимодействия с этим объектом. В противном случае, если мы будем работать с конкретной реализацией, то у нас будет жесткая зависимость на "кишки" объекта, и при изменении его структуры разработчику придется переписывать все классы, которые работают с этой реализацией.

❌ Неправильно зависеть от реализации Character, потому что при
перемещении запроса на атаку в структуре объекта, придется переписывать
контроллер.

public sealed class AttackController : MonoBehaviour
{
    [SerializeField]
    private Character character;
    
    private void Update()
    {
        if (!Input.GetKeyDown(KeyCode.Space))
        {
            return;
        }

        this.character.core.attackRequest.Invoke(); //Нарушение инкапсуляции
    }
}
✅  Правильно зависеть от абстракции AtomicObject

public sealed class AttackController : MonoBehaviour
{
    [SerializeField]
    private AtomicObject character;
    
    private void Update()
    {
        if (!Input.GetKeyDown(KeyCode.Space))
        {
            return;
        }
        
        var attackRequest = this.character.Get<IAtomicAction>("AttackRequest");
        attackRequest.Invoke();
    }
}

Но тут закрадывается такой момент, что не всегда бывает удобно работать через AtomicObject. Иногда нам нужен более конкретный интерфейс. Например, мы знаем, что в проекте у всех объектов аптечек всегда есть действие "подобрать" и свойство: сколько здоровья они восстанавливают. Поэтому можно работать с такими объектами через интерфейс C#:

//❌ Сомнительно, но окей, слишком абстрактно
void PickUpHealing(IAtomicObject obj)
{
    if (obj.Is("Pickable") && obj.Is("Healing"))
    {
        var healingPoints = obj.Get<IAtomicValue<int>>("HealingPoints").Value;
        this.restoreHealthAction.Invoke(healingPoints);
        
        obj.Get<IAtomicAction>("PickUp").Invoke();
    }
}


//✅ Делаем статический интерфейс
void PickUpHealing(IAtomicObject obj)
{
    if (obj is IHealingItem item)
    {
        var healingPoints = item.HealingPoints.Value;
        this.restoreHealthAction.Invoke(healingPoints);
        
        item.PickUpAction.Invoke();
    }
}

При этом интерфейс аптечки будет выглядеть так:

[Is("Pickable", "Healing")]
public interface IHealingItem : IAtomicObject
{
    [Get("PickUp")]
    IAtomicValue<int> HealingPoints { get; }
    
    [Get("HealingPoints")]
    IAtomicAction PickUpAction { get; }
}

Интерфейс аптечки реализует интерфейс атомарного объекта, и фреймворк сканирует публичные свойства интерфейса IHealingItem в AtomicObject.

Таким образом, реализация аптечки в проекте выглядит так:

//Не нужно указывать атрибуты, поскольку они указаны в интерфейсе:
public sealed class HealingItem : AtomicObject, IHealingItem
{
    public IAtomicAction PickUpAction => this.pickUpAction;

    public IAtomicValue<int> HealingPoints => this.healingPoints;

    [SerializeField]
    private AtomicAction pickUpAction;
    
    [SerializeField]
    private AtomicVariable<int> healingPoints;
    
    [SerializeField]
    private new Collider collider;
    
    [Header("View")]
    [SerializeField]
    private GameObject mesh;
    
    [SerializeField]
    private ParticleSystem pickUpVFX;

    private void Awake()
    {
        this.Compose();
    }

    public override void Compose()
    {
        base.Compose();
        
        this.pickUpAction.Use(() =>
        {
            this.pickUpVFX.Play(true);
            this.mesh.SetActive(false);
            this.collider.enabled = false;
        });
    }
}

История с абстрактными классами тоже работает:

[Is("Weapon")]
public abstract class Weapon : AtomicObject
{
    [Get("Config")]
    public abstract WeaponConfig Config { get; }

    [Get("CanFire")]
    public abstract IAtomicValue<bool> CanFire { get; }

    [Get("Owner")]
    public AtomicVariable<IAtomicObject> owner;

    public TeamOwnerFunction ownerTeam;

    public override void Compose()
    {
        base.Compose();
        this.ownerTeam.Use(this.owner);
    }
}

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

Динамическая модель

Довольно часто бывает такая ситуация, что поведение персонажа может меняться в зависимости от различных характеристик. Такой характеристикой у меня в проекте стало "текущее оружие персонажа". В зависимости от текущего оружия у персонажа менялась анимация и способы атаки. Например, если в руках оружие ближнего боя, то персонаж мог делать комбо удары, а если дальнего, то у персонажа появлялась возможность целиться и стрелять одновременно. При этом стрельба из тяжелого оружия немного встряхивало камеру.

Поэтому каждое оружие тянуло за собой дополнительный блок логики, которая должна была где-то работать. Немного подумав, я сделал для каждого оружия свой контроллер, который и будет выполнять эту логику.

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

//Управляет контроллерами при смене оружия:
public sealed class WeaponBehaviour : MonoBehaviour, 
    IInitializable, 
    IDisposable
{
    private AtomicBehaviour owner;
    private AtomicVariable<Weapon> currentWeapon;

    private object currentController;

    public void Compose(
        AtomicBehaviour owner,
        AtomicVariable<Weapon> weaponVariable
    )
    {
        this.owner = owner;
        this.currentWeapon = weaponVariable;
    }

    public void Initialize()
    {
        this.currentWeapon.Subscribe(this.OnWeaponChanged);
        this.OnWeaponChanged(this.currentWeapon.Value);
    }

    public void Dispose()
    {
        this.currentWeapon.Unsubscribe(this.OnWeaponChanged);
    }

    private void OnWeaponChanged(Weapon weapon)
    {
        //Отключаем предыдущий контроллер оружия из игрового объекта
        this.owner.RemoveLogic(this.currentController);

        //Создаем новый контроллер оружия
        this.currentController = weapon.Config.InstantiateWeaponController(
            this.owner, weapon
        );

        //Подключаем новый контроллер оружия к игровому объекту
        this.owner.AddLogic(this.currentController);
    }
}

При этом атомарный объект, который может динамически подключать и отключать механики должен наследоваться от класса AtomicBehaviour. AtomicBehaviour — это наследник AtomicObject, но при этом дополнительно умеет вызывать механики через интерфейсы. Поскольку в проекте у меня используется Zenject, то реализация AtomicBehaviour у меня работает с интерфейсами этого фреймворка:

public abstract class AtomicBehaviour : AtomicObject
{
    internal List<IInitializable> initializables;
    internal List<IDisposable> disposables;
    internal List<ITickable> tickables;
    internal List<IFixedTickable> fixedTickables;
    internal List<ILateTickable> lateTickables;

    public override void Compose()
    {
        base.Compose();

        this.initializables = new List<IInitializable>();
        this.disposables = new List<IDisposable>();
        this.tickables = new List<ITickable>();
        this.fixedTickables = new List<IFixedTickable>();
        this.lateTickables = new List<ILateTickable>();
    }
    
    protected virtual void OnEnable()
    {
        for (int i = 0, count = this.initializables.Count; i < count; i++)
        {
            var initializable = this.initializables[i];
            initializable.Initialize();
        }
    }

    protected virtual void OnDisable()
    {
        for (int i = 0, count = this.disposables.Count; i < count; i++)
        {
            var disposable = this.disposables[i];
            disposable.Dispose();
        }
    }
    
    protected virtual void Update()
    {
        for (int i = 0, count = this.tickables.Count; i < count; i++)
        {
            this.tickables[i].Tick();
        }
    }
    
    protected virtual void FixedUpdate()
    {
        for (int i = 0, count = this.fixedTickables.Count; i < count; i++)
        {
            this.fixedTickables[i].FixedTick();
        }
    }

    protected virtual void LateUpdate()
    {
        for (int i = 0, count = this.lateTickables.Count; i < count; i++)
        {
            this.lateTickables[i].LateTick();
        }
    }

    public void AddLogic(object target)
    {
        if (target == null)
        {
            return;
        }

        if (target is IInitializable initializable)
        {
            this.initializables.Add(initializable);

            if (this.enabled)
            {
                initializable.Initialize();
            }
        }

        if (target is ITickable tickable)
        {
            this.tickables.Add(tickable);
        }

        if (target is IFixedTickable fixedTickable)
        {
            this.fixedTickables.Add(fixedTickable);
        }

        if (target is ILateTickable lateTickable)
        {
            this.lateTickables.Add(lateTickable);
        }

        if (target is IDisposable disposable)
        {
            this.disposables.Add(disposable);
        }
    }

    ...
}

В результате, класс Character наследуется от AtomicBehaviour, чтобы ему можно было гибко добавлять механики.

Аналогичные трюки можно делать и добавлением данных в AtomicObject. Поскольку базовый класс является просто контейнером, то в него можно добавлять любые ссылки и типы.

public class AtomicObjectBase : MonoBehaviour, IAtomicObject
{
    protected internal ISet<string> types;
    protected internal IDictionary<string, object> references;

    public bool Is(string type)
    {
        return this.types.Contains(type);
    }

    public T Get<T>(string key) where T : class
    {
        if (this.references.TryGetValue(key, out var value))
        {
            return value as T;
        }

        return default;
    }

    public bool TryGet<T>(string key, out T result) where T : class
    {
        if (this.references.TryGetValue(key, out var value))
        {
            result = value as T;
            return true;
        }

        result = default;
        return false;
    }

    public IEnumerable<string> GetTypes()
    {
        return this.types;
    }

    public IEnumerable<KeyValuePair<string, object>> GetDataSet()
    {
        return this.references;
    }

    public bool AddData(string key, object value)
    {
        return this.references.TryAdd(key, value);
    }

    public void SetData(string key, object value)
    {
        this.references[key] = value;
    }

    public bool RemoveData(string key)
    {
        return this.references.Remove(key);
    }

    public void OverrideData(string key, object value, out object prevValue)
    {
        this.references.TryGetValue(key, out prevValue);
        this.references[key] = value;
    }

    public bool AddType(string type)
    {
        return this.types.Add(type);
    }

    public bool RemoveType(string type)
    {
        return this.types.Remove(type);
    }

    public virtual void Compose()
    {
        this.types = new HashSet<string>(1);
        this.references = new Dictionary<string, object>(1);
    }
}

В проекте мне нужно было провернуть такой трюк только один раз для подбираемого оружия, а именно сделать флажок в инспекторе: есть ли у этого оружия патроны или нет:

public sealed class WeaponItem : AtomicObject, IWeaponItem
{
    public WeaponConfig Config
    {
        get { return this.config; }
    }

    public IAtomicAction PickUpAction
    {
        get { return this.pickUpAction; }
    }

    [SerializeField]
    private WeaponConfig config;

    [SerializeField]
    private AtomicAction pickUpAction;

    //Флажок в испекторе
    [SerializeField, Space]
    private bool hasCharges;

    [SerializeField, ShowIf(nameof(hasCharges))]
    private int chargeAmount;

    private void Awake()
    {
        this.Compose();
    }

    public override void Compose()
    {
        base.Compose();
        this.pickUpAction.Use(() => Destroy(this.gameObject));

        //Если флажок в инспекторе есть, то у объекта есть патроны.
        if (this.hasCharges)
        {
            this.AddData(ItemAPI.ChargeAmount, 
                          new AtomicValue<int>((this.chargeAmount));
        }
    }
}

Если говорить в целом, то добавлять различные данные игровому объекту возможно и в других ситуациях, например получение брони или абилки. Думаю, суть вы поняли.

Полезные фишки

Теперь я хотел бы поделиться рядом полезных фишек, которые помогут более комфортно и эффективно работать на атомарном подходе.

Вынести хард-код в константы

Для того, чтобы можно было удобно редактировать идентификаторы, рекомендую сделать 2 файла. Первый файл для констант-типов, второй — для констант-ссылок. Показываю, как это сделано в проекте:

//Файл ObjectType.cs

public static class ObjectType
{
    public const string Character = nameof(Character);
    public const string Unit = nameof(Unit);
    public const string Weapon = nameof(Weapon);
    public const string Bullet = nameof(Bullet);
  
    public const string Healing = nameof(Healing);
    public const string Pickable = nameof(Pickable);
    public const string Healthable = nameof(Healthable);
    public const string TakeDamagable = nameof(TakeDamagable);
}
//Файл ObjectAPI.cs

public static class TransformAPI
{
    public const string Transform = nameof(TransformAPI.Transform);
    public const string Position = nameof(TransformAPI.Position);
    public const string Rotation = nameof(TransformAPI.Rotation);
    public const string Forward = nameof(TransformAPI.Forward);
}

public static class MovementAPI
{
    public const string Destination = nameof(MovementAPI.Destination);
    public const string MoveDirection = nameof(MovementAPI.MoveDirection);
    public const string IsMoving = nameof(MovementAPI.IsMoving);
    public const string MoveAction = nameof(MovementAPI.MoveAction);
}

public static class ItemAPI
{
    public const string PickUp = nameof(ItemAPI.PickUp);
    public const string ChargeAmount = nameof(ItemAPI.ChargeAmount);
    public const string HealingPoints = nameof(ItemAPI.HealingPoints);
}

... 

//и так далее

Лайфхак: если вам нужно перенести константу из одного класса в другой, так чтобы у вас не сломался проект, можно сделать в рефакторинг IDE следующим образом: выбрав Refactor -> Move To Another Type (Rider).

Сделать расширения для IAtomicObject

Чтобы не писать длинные обращения к атомарным структурам через дженерики, типа:

 var healingPoints = obj.Get<IAtomicValue<int>>("HealingPoints").Value;

можно сделать следующие расширения:

public static IAtomicValue<T> GetValue<T>(this IAtomicObject it, string name)
{
    return it.Get<IAtomicValue<T>>(name);
}

public static void InvokeAction(this IAtomicObject it, string name)
{
    it.GetAction(name)?.Invoke();
}

применив фишки, получим следующий код-стайл:

if (obj.Is(ObjectType.Pickable) && obj.Is(ObjectType.Healing))
{
    var healingPoints = obj.GetValue(ItemAPI.HealingPoints).Value;
    this.restoreHealthAction.Invoke(healingPoints);
    
    obj.Invoke(ItemAPI.PickUp);
}

Фреймворк под капотом

Если говорить вкратце, как это все работает, то под капотом атомарного фреймворка это все работает на рефлексии. Когда у атомарного объекта вызывается метод AtomicObject.Compose(), то начинается сканирование всей структуры игрового объекта. Конечно, все сделано с кэшированием типов и полей, и при повторном создании игрового объекта рефлексия не вызывается (ну почти).

Выводы

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

  1. Декларативный стиль. Игровой объект описывается в виде композиции, что позволяет собирать игровой объект как конструктор.

  2. Разделение данных и логики. Позволяет переиспользовать структуры данных и игровые механики между объектами

  3. Абстракция и полиморфизм. Позволяет объектам разных типов обрабатываться как экземпляры общего базового типа и универсального типа. Это обеспечивает гибкость и инкапсуляцию в работе с игровым объектом.

  4. Динамическая структура. Позволяет добавлять данные и логику игровому объекту в процессе работы программы.

Недостатки

Основными недостатками атомарного подхода я виду следующие:

  1. Доп. расходы памяти. Поскольку реализация игровых объектов на атомарном подходе требует создание множество экземпляров ссылочных типов, то и памяти будет требоваться больше, чем при описании объекта через классический Object-Oriented Design;

  2. Рефлексия. Использование рефлексии при создании объекта тормозит производительность. Это может вызывать лаги в процессе геймплея. Поэтому лучше делать запекание типов на этапе загрузки.

  3. Создание структуры объектов через код. С одной стороны это очень удобно контролировать и поддерживать структуру объекта в коде и меньше зависеть от Unity, с другой стороны: если на проекте будет необходимо создавать 100 вариаций врагов, которые на 95% схожи по структуре, то придется копипастить классы врагов и называть их типа: EnemyV1, EnemyV2 и так далее. Были мысли сделать нодовый редактор для всего этого дела, но пока не готов идти на такой шаг... Поэтому для реализации сценариев поведений рекомендую использовать Behaviour Tree поверх атомарного подхода.

На самом деле поработав на атомарном подходе, я снова полюбил монобехи! Оказывается, это очень удобный инструмент, особенно когда нужно сделать различные комбинации сюжетов на уровне. Поэтому монобехов тоже не нужно избегать, они нужны!

Подводя итоги, скажу, подход полностью подчиняется объектно ориентированному программированию. В нем есть инкапсуляция, наследование и полиморфизм. Конечно, концепция не идеальна, но имеет место! При правильном применении можно делать интересные механики и переиспользовать их в других проектах. Надеюсь получилось раскрыть методологию атомарного подхода!

В завершении скажу, что более подробная информация будет на онлайн-курсе по атомарному подходу. Также, если у вас будет желание посетить мой телеграмм канал, буду рад! Благодарю! 🙏

Стримы по атомарному подходу:

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

  2. Компоненты и секции

  3. Динамические объекты

Ссылка на демо‑проект в Unity

Ссылка на библиотеку в Github

Теги:
Хабы:
Всего голосов 5: ↑3 и ↓2+1
Комментарии23

Публикации

Истории

Работа

Ближайшие события

12 – 13 июля
Геймтон DatsDefense
Онлайн
19 сентября
CDI Conf 2024
Москва