Скриншот мобильного зомби шутера
Скриншот мобильного зомби шутера

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

Меня зовут Игорь, и я 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 поверх атомарного подхода.

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

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

В завершении скажу, что я буду реализовывать персонажа на атомарном подходе 20-го декабря в 19:00 по МСК на Youtube у себя на канале. Более подробная информация будет на онлайн-курсе по атомарному подходу. Также, если у вас будет желание посетить мой телеграмм канал, буду рад!

Благодарю! ????

Комментарии (22)


  1. WNeZRoS
    19.12.2023 07:50

    Выглядит всё это как переизобретение ECS в менее оптимальном виде. А пример с PickUpHealing и "Вынести хард-код в константы" говорят о том что весь этот фреймворк не удобен и лучше делать на обычных интерфейсах: это будет и быстрее работать и проверок во время компиляции больше будет.


    1. StarKRE Автор
      19.12.2023 07:50

      Не, у ECS есть свои тараканы и там практически нет полиморфизма.

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


      1. HexGrimm
        19.12.2023 07:50

        Тараканы в статье точно такие же как и у ECS, тут нету никакого выигрыша. Если поставить задачку реиспользовать систему ECS для разных типов данных, то получится точно так же:
        - Отдельно код проброса геттеров из конкретных компонентов в абстрактную общую часть логики из системы.
        - Отдельная абстрактная часть логики, в которой работа происходит точно так же как и через IAtomicValue, но через геттеры, чтобы не завязываться на сигнатуру конкретных компонентов.
        Просто в ECS всем обычно лень писать геттеры и связывание на каждый универсальный чих, и по этому в универсальную логику пихают целые компоненты, дополнительно делая сами компоненты более универсальными и атомарными.
        Так что аргумент, что это неECS, в конкретном частном исполнении, имхо, не засчитан.


        1. StarKRE Автор
          19.12.2023 07:50

          Не совсем понял примеры с кодом проброса геттеров из конкретных компонентов, можешь, плиз, привести пример кода?

          Ключевое отличие атомарного подхода от ECS заключается в том, что атомарный подход остается в парадигме ООП, а ECS нет. Атомарный подход подчиняется принципам ООП: инкапсуляция, наследование, полиморфизм, а ECS — нет.

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

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


          1. Lekret
            19.12.2023 07:50

            Не знаю насколько в атомарном подходе остаётся жива инкапсуляция, мы же вроде данные и логику разделили, и добавили возможность получать переменные напрямую из объекта, что как-то с инкапсуляцией не вяжется. Логика данными тоже не владеет, мы данные прокидываем извне, и данными по факту владеет сам объект переменной, который их никак не защищает и про который могут знать разные куски логики.

            Полиморфизм легко достигается в ECS через компоненты, ну либо в крайнем случае можно также использовать интерфейсы, если реализаций много.
            Конструкция объектБезТипа.Is("Хилка") ничем по факту от полиморфизма в ECS не отличается, кроме того, что ECS быстрее, типобезопаснее и поддерживает фильтры/группы/query.

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


      1. supremestranger
        19.12.2023 07:50

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


  1. Grave18
    19.12.2023 07:50

    Если все это работает на рефлексии, то зачем типизировать через string, ели можно использовать Type?


    1. StarKRE Автор
      19.12.2023 07:50

      В атомарном подходе тип — это просто маркер или тэг объекта.


  1. HexGrimm
    19.12.2023 07:50

    С точки зрения кода любой игровой объект состоит из данных и логики

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

    • Сериализация состояния мира в больших масштабах.

    • Тестирование и воспроизведение конкретного кейса.

    • Декларирование порядка вызовов между атомарными объектами или внешнего кода.

    • Копирование объектов (сложно оценить как скопируются функции и эвенты)

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


    1. StarKRE Автор
      19.12.2023 07:50

      Не оч понял аргумент, почему сущность теряет гибкость и тестируемость, если объект состоит из данных и логики?


      1. HexGrimm
        19.12.2023 07:50

        В именно этой реализации, данные и логика не отделены друг от друга полностью, а имеют важные детали в методе Compose(). И этот метод собственно деструктивно инициализирует данные и логику. Нельзя как в ECS взять тот же компонент, но от другой сущности, чтобы всё продолжило работать.


        1. StarKRE Автор
          19.12.2023 07:50

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

          Также если нужно, можно добавлять и удалять данные объекта в Run-time по аналогии с добавлением и удалением компонентов в сущности. (см главу про Динамические объекты)


    1. StarKRE Автор
      19.12.2023 07:50

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


      1. HexGrimm
        19.12.2023 07:50

        Функцией сериализации придется наделить либо все сущности, либо каким то образом шарить дополнительные детали из метода Compose() (о чём я писал выше). Это будет дороговато поддерживать, и в целом это доступ к рандомной памяти в куче, в большом количестве итераций. Например, файтинг или шутер, где нужно делать копию мира по 7-8 раз за кадр, так не сделать.


        1. StarKRE Автор
          19.12.2023 07:50

          А зачем сериализовывать все? Если мы говорим про мультиплеер, то достаточно сериализовать состояние объекта (то есть данные) и синхронизировать их с игровым объектом на сервере.


          1. HexGrimm
            19.12.2023 07:50

            По коду сложно сказать достаточно или нет, тк сам контракт подразумевает что данные можно трактовать по разному. Тут ведь при синхронизации для данных нужен еще факт создания или удаления сущности, и какого она была типа (набора компонентов данных + набора логических систем + связей между объектами). Если бы вся информация об этом была гарантирована в данных, то было бы надёжнее.
            Ну или я просто не правильно представляю рабочие примеры, а не синтетические. Мне кажется что подход не полностью датаориентированный, тк тут замешаны ООП свойства, и я перечислил список частых проблем в недатаориентированных средах.


          1. HexGrimm
            19.12.2023 07:50

            Да, еще забыл сказать, необходимо мочь сериализовать всё, иначе результаты вычислений будут расходиться. Или, например, если игрок только подключился и у него совсем нет информации о мире. Если работать с сущностями по одной, то получится неконсистентность данных, если делать в лоб. Такой подход есть в Unreal, или в NetworkForGameObjects, но в играх так совсем уже не делают. В современных тайтлах всегда где нибудь да есть предсказание, или компенсация лага.


  1. DobrijBarin
    19.12.2023 07:50

    Нихрена не понял зачем это все использовать. Когда мы отделяем логику от данных, мы делаем декомпозицию таким образом, чтобы логика и данные вообще никоем образом не пересекались в архитектуре, у вас в вашем атомарном подходе логика и данные это один класс, вы не разделили логику и данные. Это делается для того, чтобы мы могли написать тесты, переиспользовать компоненты проекта, легко редактировать, раширять итд. Но, ваш подход - это монокласс с логикой и данными в одном месте. А прикол с Is вообще не понял, т.е. Вы создаёте ядро в котором одновременно сосуществует логика и данные, при это компонент может быть использован в строго заранее определённых паттернах и задачах. Обычно это делается не так, а через интерфейсы, мы можем создать определённые контракты, которые на базовом уровне класса просто не дадут его использовать не по назначению. Оттого этот подход мне кажется абсолютно не применим в играх. Это я ещё не стал изучать на предмет оптимизации вычислений в подобном подходе. Но, вы за меня сделали вывод, что это не самый оптимальный вариант, как раз в своём резюме.


    1. StarKRE Автор
      19.12.2023 07:50

      Вы просто не поняли :)


  1. Vadimskyi
    19.12.2023 07:50

    Дочитал до "Is". Зачем этот велосипед с константными строками, используйте интерфейсы.

    Прикиньте на сколько удобнее будет код если вместо

    character.Is("TakeDamagable");

    будет

    character is IDamagable

    Но зачем вообще проверять персонажей на компонент, если можно работать сразу с массивами компонентов и получить бесплатный буст к производительности? Ой, похоже я придумал ECS...


    1. StarKRE Автор
      19.12.2023 07:50

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

      character.Is("TakeDamagable");

      Это и есть интерфейс, просто динамически типизированный.

      Но сделать вот так на атомарном подходе тоже можно (писал про пример с аптечкой)

      character is IDamagable


  1. SergeyZX
    19.12.2023 07:50

    Во загнул:)