Описываем игровые объекты в виде модели атома

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

Меня зовут Игорь, и я Unity Developer. За 5 лет накопилось много опыта, и я хотел бы поделиться с вами новым подходом, с помощью которого можно описывать игровые объекты декларативно внутри и компонентно снаружи.

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

Начну немного издалека...

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

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

Ну что поехали...

Текущая ситуация на игровом рынке

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

Таким образом, на этапе минимальной версии продукта (MVP) могут добавляться, изменяться и удаляться фичи в игре. Это означает, что при тестировании различных гипотез, разработчик должен легко и быстро внести в код изменения. Следовательно, код должен быть гибкий.

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

Основные подходы разработки игровых объектов

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

  • Объектно-ориентированный подход (ООП)

  • Компонентно-ориентированный подход (КОП)

  • Entity Component System (ECS)

Рассмотрим вкратце преимущества и недостатки каждого из подходов.

Объектно-ориентированный подход

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

public sealed class Hero {
  
  public int hitPoints;  
  public int damage;
  public int speed;   
  
  public void Move(Vector3 direction) {
    //Move logic...    
  }

  public void Attack(GameObject target) {
    //Attack logic...
  }
  
  public void Jump() {       
    //Jump logic...
  }
}

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

Но что, если нам нужно повторно использовать, механики перемещения атаки и здоровья в объекте противника? Это может быть проблематично, поскольку эта функциональность жестко зашита в классе Hero. Да, можно создать новый класс Enemy и "скопипастить" туда эту логику, но тогда у нас будет дублирование кода, которое усложнит поддержку кода, так как вносить изменения нужно будет сразу в несколько участков программы.

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

public class Unit
{
    public int hitPoints;
    public int damage;
    public int speed;

    public virtual void Move(Vector3 position) {
        //Move logic...
    }

    public void Attack(GameObject target) {
        //Attack logic...
    }
}


public sealed class Hero : Unit
{
    public void Jump() {
        //Jump logic...
    }
}

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

//Башня
public sealed class Tower : Unit {
  
  //speed not used...     
  
  public override void Move(Vector3 position) { 
    //Not moving...    
  }
}

Рано или поздно, используя наследование, разработчик начнет "костылять", когда будет использовать полиморфизм, обращаясь к базовому классу Unit. Например, когда нужно будет нарисовать на сцене дополнительный путь перемещения к точке назначения:

public void MoveCommandGUI(Unit unit, Vector2 screenPoint) 
{
    if (unit is not Tower) 
    {
        //Screen point to world position...
        ///...
        
        unit.Move(worldPosition);    
      
        // Дополнительно рисуем путь к точке на карте...
        this.DrawMoveRoad(worldPosition); 
    }
}

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

В результате, прежде чем начать реализацию игровых объектов с помощью ООП классов разработчику нужно ответить на один вопрос: "структура игровых объектов фиксированная или плавающая"? Другими словами, функциональность игровых объектов может комбинироваться в игре или нет? Если ответ положительный, то это означает, что вам нужно отказаться от написания классов для каждого игрового объекта и перейти на уровень компонентов.

Компонентно-ориентированное программирование

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

var hitPoints = gameObject.GetComponent<HitPointsComponent>();  
var attackComponent = gameObject.GetComponent<AttackComponent>();  
var moveComponent = gameObject.GetComponent<MoveComponent>();

Думаю, что многие читатели поняли, что это способ, который предлагает игровой движок Unity по умолчанию:

Если вернуться к примеру с классами Hero и Enemy, где мы хотим повторно использовать механики, то как раз от дублирования кода мы можем уйти за счёт композиции, создав компоненты MoveComponent, HitPointsComponent и AttackComponent:

public sealed class HitPointsComponent : MonoBehaviour { 

  public int hitPoints;
}


public sealed class MoveComponent : MonoBehaviour {  

  public int speed;   

  public void Move(Vector3 direction) {   
    //Move logic...    
  }
}


public sealed class AttackComponent : MonoBehaviour {
  
  public int damage;     
  
  public void Attack(GameObject target) { 
    //Attack logic...    
  }
}

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

Таким образом, у башни просто не будет компонента перемещения.

Но есть пара нюансов, которые нужно учитывать при работе с компонентами, чтобы не выстрелить себе в ногу:

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

  2. Каждый компонент не должен выполнять большую ответственность. Когда я делал пошаговую игру, то у меня был класс MoveComponent, который помимо перемещения по Transform, отыгрывал покадровую анимацию движения, воспроизводил звук мотора, плюс запускал партиклы. По началу все казалось радужно, но как появилась задача сделать корабль, который перемещается без анимации и партиклов, сразу же пришлось костылять if-else. К сожалению, такой код не получилось повторно использовать в других проектах.

Таким образом, я думаю, что КОП имеет смысл использовать только в том случае, когда соблюдаются следующие условия:

  1. Структура игровых объектов является динамической;

  2. Каждый компонент выполняет небольшую ответственность;

  3. Каждый компонент является модульным.

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

public sealed class Reward : ScriptableObject
{
  [SerializeReference]
  private IRewardComponent[] components;

  public T GetComponent<T>()
  {
    foreach (var component in this.components)
    {
      if (component is T tComponent)
      {
          return tComponent;
      }
    }

    throw new Exception($"Component of type {typeof(T).Name} is not found!");
  }
}

Entity Component System

Теперь давайте рассмотрим один самых интересных паттернов, который стал популярен в последние годы — это Entity Component System (ECS). Если говорить вкратце, то суть паттерна заключается в том, что в качестве игрового объекта выступает сущность — Entity. У каждой Entity есть свои данные, которые называются Components. И чтобы выполнять различные операции над сущностями и данными используются системы – Systems.

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

Например, механика гравитации на ECS выглядит примерно так:

public struct Coordinates {
  public float x;
  public float y;
}

public sealed class GravitySystem : ISystem {
  
  private readonly Entity[] entities; 

  void ISystem.Update()
  {
    var dGravity = 9.8f * Time.deltaTime;
    
    foreach (var entity in this.entities)
    {
      ref var coordinates = ref entity.Get<Coordinates>();
      coordinates.y -= dGravity;
    }
  }
}

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

  • Во-первых, ECS позволяет реализовывать игровую логику модульно: создал новый компонент с данными, написал новую систему, подключил – все работает;

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

  • В-третьих, ECS обеспечивает высокую производительность за счёт расположения данных большими смежными блоками, что позволяет обрабатывать данные последовательно;

  • В-четвёртых, благодаря ECS, можно легко "распараллелить" обработку данных на несколько потоков с минимальной синхронизацией между ними, что увеличит производительность игры; 

  • В-пятых, легко распределять задачи между десятками разработчиков в крупных проектах, так как каждый программист будет реализовывать свои компоненты и системы вне зависимости от других;

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

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

Поскольку я являюсь новичком в ECS, то вижу недостатки, которые вызывают у меня сложности, несмотря на то, что сам паттерн достаточно прост:

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

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

  • В-третьих, вам нужно хорошо продумать архитектуру взаимодействия ECS с другими модулями, которые написаны на ООП. Потому что помимо механик у вас будут системы сохранения, игровой интерфейс, локализация , аналитика, встроенные покупки и так далее...

  • В четвёртых, вставлю пять копеек про Unity. Изначально игровой движок не был заточен под ECS. И когда этот подход стал набирать популярность, компания Unity сделала технологию Data-Oriented Technology Stack (DOTS). Поэтому если вы хотите использовать это в Unity с максимальной производительностью, то вам придется хорошо разобраться с DOTS и понять, как он работает под капотом. Также рекомендую ознакомиться с альтернативными решениями ECS: LeoEcs, Entitas и Morpeh. (Кстати, дорогой читатель, вступай в чат про ECS, если тебя еще там нет).

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

Но это мое субъективное мнение, и я могу ошибаться. Так что, потихоньку осваиваю ????

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

Игровой объект в виде модели атома
Игровой объект в виде модели атома

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

Концепция атомарного подхода

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

Ядро объекта

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

Для наглядности сразу предлагаю рассмотреть следующий пример:

У персонажа есть переменная "здоровье" и возможность "получения урона". Когда "здоровье" равно нулю, происходит событие "смерть персонажа".

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

Ядро персонажа
Ядро персонажа

Теперь напишем, код, который будет реализовывать набор этих механик:

public sealed class CharacterModel
{
    //Переменная здоровья:
    public Variable<int> hitPoints = new(); 

    //Событие получения урона:
    public Emitter<int> onTakeDamage = new(); 

    //Событие смерти:
    public Emitter onDeath = new(); 

    //Метод инициализации:
    public void Construct()
    {
        this.onTakeDamage.AddListener(damage =>
        {
            this.hitPoints.value -= damage;
        });
        this.hitPoints.AddListener(value =>
        {
            if (value <= 0) this.onDeath.Invoke();
        });
        this.onDeath.AddListener(() =>
        {
            Debug.Log("Character is died!");
        });
    }
}

В данном случае мы использовали универсальные элементы: триггер события (класс Emitter<T>) и переменная (класс Variable<T>), а их взаимодействие описали с помощью лямбда-выражений в специальном методе инициализации Construct().

В качестве триггера событий можно сделать общий класс Emitter<T>, на который можно будет подписывать обработчики события и вызывать его с помощью метода Invoke():

public sealed class Emitter<T>
{
    private Action<T> onEvent;

    public void AddListener(Action<T> listener)
    {
        this.onEvent += listener;
    }

    public void RemoveListener(Action<T> listener) 
    {
        this.onEvent -= listener;
    }

    public void Invoke(T args)
    {
        this.onEvent?.Invoke();
    }
}

А для работы с переменными можно сделать универсальный класс Variable<T>, который будет не только хранить значение, но и оповещать о ее изменении:

public sealed class Variable<T>
{
    public T value
    {
        get { return _value; }
        set
        {
            _value = value;
            onChanged?.Invoke(value);
        }
    }

    private T _value;

    private Action<T> onChanged;

    public void AddListener(Action<T> listener)
    {
        this.onChanged += listener;
    }

    public void RemoveListener(Action<T> listener) 
    {
        this.onChanged -= listener;
    }
}

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

Теперь, если нужно будет нанести урон персонажу извне, то мы можем взять экземпляр. класса CharacterModel и вызвать у него событие onTakeDamage:

//Псевдо-контроллер:
class DamageController 
{ 
    void DealDamage(CharacterModel target, int damage)
    {
        target.onTakeDamage.Invoke(damage);
    
        var remainingHitPoints = target.hitPoints.value;
        Debug.Log("Remaining hit points:" + remainingHitPoints);
    }
}

Вот так можно взаимодействовать с ядром игрового объекта.

Разделы объекта

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

public sealed class CharacterModel
{
  public Life life = new(); 
  public Attack attack = new();

  //Раздел здоровья:
  public sealed class Life
  {
     public Variable<int> hitPoints = new();
     public Emitter<int> onTakeDamage = new();
     public Emitter onDeath = new();
  
     public void Construct()
     {
        this.onTakeDamage.AddListener(damage =>
        {
            this.hitPoints.value -= damage;
        });
        this.hitPoints.AddListener(value =>
        {
            if (value <= 0) this.onDeath.Invoke();
        });
        this.onDeath.AddListener(() =>
        {
            Debug.Log("Character is died!");
        });
     }
  }

  //Раздел атаки:
  public sealed class Attack
  {
     public Variable<int> damage = new();
     public Emitter<ITarget> onAttack = new();
  
     public void Construct()
     {
       this.onAttack.AddListener(target =>
       {
           target.TakeDamage(this.damage.value);
       });
     }
  }
}

В результате структура нашего персонажа разбивается на вспомогательные классы: Life и Attack, где каждый из них является разделом и описывает свою механику. Если говорить грубо, то фактически задача разработчика заключается в том, чтобы интерпретировать описание из геймдизайн-документа в код.

Основные разделы: Core & View

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

  • В разделе Core мы описываем данные и логику игрового объекта. Это то, без чего наш персонаж не будет работать. В результате, код класса CharacterModel, который я привел выше в качестве примера, должен находится в разделе Core.

  • В разделе View мы описываем визуальное и аудиальное представление игрового объекта на сцене. Это могут быть анимации, звуки, визуальные эффекты, мини-интерфейс и так далее...

Например, нужно реализовать логику анимации, когда происходит событие "гибели персонажа":

public sealed class CharacterModel 
{
  public Core core = new();
  public View = new();

  //Раздел механик:
  public sealed class Core 
  {
    //Life, Attack
  }
  
  //Раздел визуала:
  public sealed class View 
  {
    public Animator animator;
  
    public void Construct(Core core)
    {
       core.life.onDeath.AddListener(() => this.animator.Play("Death", -1, 0));
    }
  }
}

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

❗️ Важное замечание про повторное использование и наследование разделов:

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

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

По ядру все, теперь давайте поговорим про оболочку...

Оболочка объекта

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

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

//Псевдо-контроллер:
class DamageController 
{
    void DealDamage(Entity character, int damage)
    {
        character
          .GetComponent<ITakeDamageComponent>()
          .TakeDamage(damage);
    }
}

Фактически, здесь используется компонентный подход, который я описывал выше. Использование КОП позволяет сделать интерфейс взаимодействия с сущностью более общим, не раскрывая реализации объекта под капотом.

Класс Entity – это по-прежнему контейнер, который хранит в себе набор компонентов, а вот компонент TakeDamageComponent — уже не хранит в себе данные и логику, теперь он является моделью представления для события onTakeDamage из ядра.

//Контейнер для компонентов:
public class Entity 
{
  private readonly object[] components;

  public Entity(params object[] components)
  {
    this.components = components;  
  }

  public T GetComponent<T>()
  {
    foreach (var component in this.components)
    {
      if (component is T tComponent)
      {
          return tComponent;
      }
    }

    throw new Exception($"Component of type {typeof(T).Name} is not found!");
  }
}


//Компонент получения урона:
public interface ITakeDamageComponent 
{    
    event Action<int> OnDamageTaken;
    void TakeDamage(int damage);
}

public sealed class TakeDamageComponent : ITakeDamageComponent
{
    public event Action<int> OnDamageTaken
    {
        add { this.onTakeDamage.AddListener(value); }
        remove { this.onTakeDamage.RemoveListener(value); }
    }

    private readonly Emitter<int> onTakeDamage;

    public TakeDamageComponent(Emitter<int> onTakeDamage)
    {
        this.onTakeDamage = onTakeDamage;
    }

    public void TakeDamage(int damage)
    {
        this.onTakeDamage.Invoke();
    }
}

В данном примере мы видим, что класс TakeDamageComponent является провайдером к событию получения урона из ядра и не выполняет бизнес-логики. Его задача — просто быть уникальным и делегировать вызов на элемент из ядра.

В результате такой компонент является модульным и переиспользуемым, поскольку он не зависит от конкретной реализации структуры объекта. К тому же, добавление интерфейса ITakeDamageComponent делает взаимодействие с объектами еще более гибким, так как соблюдается принцип DIP.

❗️Важный момент про нейминг компонентов:

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

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

//Фабрика создания оболочки для персонажа:
public static class CharacterEntityFactory
{
  public static Entity Create(CharacterModel model) //Передаем ядро персонажа
  {
    //Наполняем оболочку персонажа компонентами:
    return new Entity(
      new TakeDamageComponent(model.life.onTakeDamage),
      new HitPointsComponent(model.life.hitPoints),
      new AttackComponent(model.attack.onAttack)
    );
  }
}

Теперь вы можете работать с разными игровыми объектами не раскрывая их реализации:

//Псевдо-контроллер:
class UnitController {

  private Entity unit;
  
  void DealDamage(int damage)
  {
      this.unit
        .GetComponent<ITakeDamageComponent>()
        .TakeDamage(damage);
  }

  void ShowHitPoints()
  {
      var hitPoints = this.unit
         .GetComponent<IHitPointsComponent>()
         .HitPoints;
    
      Debug.Log("Hit Points: " + hitPoints);
  }

  void AttackTarget(ITarget target){
      this.unit
        .GetComponent<IAttackComponent>()
        .Attack(target);
  }
}

Таким образом, компонентный подход обеспечивает высокую гибкость и поддерживаемость кода игровых объектов, а также позволяет избежать дублирования кода (DRY). Что самое главное — будет достигнут принцип OCP, благодаря которому изменения под капотом объекта не повлияют на логику работы системы игры и других объектов.

Вывод

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

Атомарный подход — это комбинация декларативного и компонентного подходов
Атомарный подход — это комбинация декларативного и компонентного подходов

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

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

Первым делом, давайте сделаем на сцене структуру персонажа из GameObject'тов:

  • "Character" — это корневой объект

    • "Model" — это визуальная часть стикмана (MeshRenderer, Animator)

    • "Audio"  — это аудио компоненты (AudioListener, AudioSource)

Реализация ядра

Давайте напишем ядро для персонажа с помощью декларативного подхода на Unity:

public sealed class CharacterModel : DeclarativeModel //MonoBehaviour
{
    [SerializeField, Section]
    public Core core = new();

    [SerializeField, Section]
    private View view = new();

    [Serializable]
    public sealed class Core
    {
        [SerializeField, Section]
        public Life life = new();

        [SerializeField, Section]
        public Attack attack = new();

        [Serializable]
        public sealed class Life
        {
            [SerializeField]
            public IntVariable hitPoints = new();

            public Emitter<int> onTakeDamage = new();

            public Emitter onDeath = new();

            [Construct]
            public void Construct()
            {
                this.onTakeDamage.AddListener(damage =>
                {
                    this.hitPoints.Current -= damage;
                });
                this.hitPoints.AddListener(value =>
                {
                    if (value <= 0) this.onDeath.Call();
                });
                this.onDeath.AddListener(() =>
                {
                    Debug.Log("Character is died!");
                });
            }
        }

        [Serializable]
        public sealed class Attack
        {
            [SerializeField]
            public IntVariable damage = new();
  
            public Emitter<IEntity> onAttack = new();
  
            [Construct]
            public void Construct()
            {
                this.onAttack.AddListener(async target =>
                {
                    await Task.Delay(750);
                    target
                      .Get<ITakeDamageComponent>()
                      .TakeDamage(this.damage.Current);
                });
            }
        }
    }

    [Serializable]
    private sealed class View
    {
        [SerializeField]
        private Animator animator;

        [SerializeField]
        private AudioSource audioSource;

        [SerializeField]
        private AudioClip attackSFX;

        [SerializeField]
        private AudioClip takeDamageSFX;
        
        [Construct]
        public void Construct(Core.Life life, Core.Attack attack)
        {
            life.onDeath.AddListener(() =>
            {
                this.audioSource.PlayOneShot(this.takeDamageSFX);
                this.animator.Play("Death", -1, 0);
            });
            
            life.onTakeDamage.AddListener(damage =>
            {
                if (life.hitPoints.Current - damage > 0)
                {
                    this.animator.Play("TakeDamage", -1, 0);
                    this.audioSource.PlayOneShot(this.takeDamageSFX);
                }
            });

            attack.onAttack.AddListener(_ =>
            {
                this.animator.Play("Attack", -1, 0);
                this.audioSource.PlayOneShot(this.attackSFX);
            });
        }
    }
}

В данном примере класс CharacterModel наследуется от специального монобеха DeclarativeModel, который инициализирует структуру наследника через рефлексию и вызывает у каждого раздела методы, которые помечены специальным атрибутом [Construct]. Также для удобства в метод Construct() можно передавать другие разделы в качестве аргументов.

Для того, чтобы базовый класс DeclarativeModel понимал какие поля являются разделами, они помечаются специальным атрибутом [Section] и добавляются в словарь разделов. Фактически, в вызове метода Construct() происходит внедрение зависимостей, а класс DeclarativeModel можно назвать своего рода DI-Container'ом.

Многие могут подумать, что инициализация структуры через рефлексию — это дорого с точки зрения производительности, и будут правы, но только для первого экземпляра объекта. Поскольку в .NET Framework есть механизм кэширования типов "Type Reflection Cache", то инициализация последующих объектов будет быстрой.

Хорошо, теперь давайте добавим компонент CharacterModel на корневой GameObject:

Структура персонажа в инспекторе
Структура персонажа в инспекторе

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

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

Реализация оболочки

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

//Интерфейс компонентов:
public interface IHitPointsComponent
{
    event Action<int> OnHitPointChanged;
    int HitPoints { get; }
}

public interface ITakeDamageComponent
{
    event Action<int> OnDamageTaken;
    void TakeDamage(int damage);
}
    
public interface IAttackComponent
{
    void Attack(IEntity gameObject);
}
//Реализация компонентов:
public sealed class HitPointsComponent : IHitPointsComponent
{
    public event Action<int> OnHitPointChanged
    {
        add { this.hitPoints.OnValueChanged += value; }
        remove { this.hitPoints.OnValueChanged -= value; }
    }

    public int HitPoints
    {
        get { return this.hitPoints.Current; }
    }

    private readonly IVariable<int> hitPoints;

    public HitPointsComponent(IVariable<int> hitPoints)
    {
        this.hitPoints = hitPoints;
    }
}


public sealed class TakeDamageComponent : ITakeDamageComponent 
{
    public event Action<int> OnDamageTaken
    {
        add { this.onTakeDamage.OnEvent += value; }
        remove { this.onTakeDamage.OnEvent -= value; }
    }

    private readonly IEmitter<int> onTakeDamage;

    public TakeDamageComponent(IEmitter<int> onTakeDamage)
    {
        this.onTakeDamage = onTakeDamage;
    }

    public void TakeDamage(int damage)
    {
        this.onTakeDamage.Call(damage);
    }
}


public sealed class AttackComponent : IAttackComponent
{
    private readonly IEmitter<IEntity> onAttack;

    public void Attack(IEntity gameObject)
    {
        this.onAttack.Call(gameObject);
    }

    public AttackComponent(IEmitter<IEntity> onAttack)
    {
        this.onAttack = onAttack;
    }
}

Дальше нужно создать сущность, в которой будут храниться компоненты для объекта персонажа:

[RequireComponent(typeof(CharacterModel))]
public sealed class CharacterEntity : MonoEntityBase
{
    private void Awake()
    {
        var model = this.GetComponent<CharacterModel>();
        
        this.Add(new HitPointsComponent(model.core.life.hitPoints));
        this.Add(new TakeDamageComponent(model.core.life.onTakeDamage));
        this.Add(new AttackComponent(model.core.attack.onAttack));
    }
}

В данном случае, я просто делаю наследника от базового контейнера MonoEntityBase и подключаю туда компоненты в методе Awake(). В результате, получаем скрипт ядра и скрипт оболочки:

Компоненты персонажа в инспекторе
Компоненты персонажа в инспекторе

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

public sealed class TestController : MonoBehaviour
{
     //Подопытный персонаж:
    [SerializeField]
    private MonoEntity character;

    //Получаемый урон в тесте:    
    [Space]
    [SerializeField]
    private int damage;

    //Можно сделать клон персонажа на сцене и побить его:
    [SerializeField]
    private MonoEntity target; 
    
    [ContextMenu("Take Damage")]
    private void TakeDamage()
    {
        this.character
            .Get<ITakeDamageComponent>()
            .TakeDamage(this.damage);
    }

    [ContextMenu("Show Hit Points")]
    private void ShowHitPoints()
    {
        var hitPoints = this.character
            .Get<IHitPointsComponent>()
            .HitPoints;

        Debug.Log("Hit Points: " + hitPoints);
    }

    [ContextMenu("Attack Target")]
    private void AttackTarget()
    {
        this.character
            .Get<IAttackComponent>()
            .Attack(this.target);
    }
}

Вот так можно реализовать персонажа с помощью атомарного подхода на Unity. Ссылки на библиотеки для АП будут в конце статьи.

Преимущества подхода

Теперь, давайте рассмотрим плюсы подхода, которые я для себя увидел, когда реализовывал игру:

  • Во-первых, это низкий порог входа. Данный подход отлично дружит с парадигмой ООП.  Достаточно просто начать описывать игровые сущности в коде с помощью универсальных элементов и лямбда-выражений. На самом деле есть еще более простой способ – сделать свое решение на монобехах и GameObject'ах. Но мне это решение показалось довольно громоздким и неоптимизированным, поэтому в библиотеке для декларативного подхода я сделал так, чтобы можно было писать классы без монобехов, используя специальные интерфейсы IEnableListener, IUpdateListener и так далее...

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

  • В-третьих, это прозрачность структуры объекта. Данный подход позволяет видеть полностью состояние игрового объекта и его функционал в инспекторе. Это упрощает тестирование механик и поиск багов. Дополнительно рекомендую подключить Odin Inspector.

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

Недостатки подхода

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

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

  • Во-вторых, не все механики удобно описывать декларативно, например логику с циклами ветвлениями лучше описывать императивно. Поэтому все равно придется создавать отдельные классы, которые будут выполнять последовательные алгоритмы.

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

  • В-четвертых, проставление зависимостей вручную. Если вы посмотрели код противника, то наверное увидели, что множество ссылок нужно проставлять вручную в методах Construct(). Это связано с тем, что нужно подключать разные экземпляры универсальных классов друг к другу. Поэтому помимо написания лямбда-выражений придется подключать поля объектов вручную.

  • В пятых, это куча примитивных классов и компонентов. Если не определить четкие конвенции в написании универсальных классов, то можно легко запутаться в скриптах. С другой стороны, чем мне нравится этот подход — это то, что он заставляет думать, как с помощью одного примитива можно описать сразу несколько механик. Поэтому скажу так: Easy to Learn, Difficult to Master!

Вывод

Таким образом, атомарный подход – это комбинация двух подходов в разработке игрового объекта: Entity-Component и Declarative Paradigm.

Поскольку подход новый, то рекомендую его рассматривать, если соблюдаются следующие условия в проекте:

  • Нужно обеспечить модульность механик игровых объектов

  • Нет времени на изучение ECS

  • Оптимизация в проекте не критична

  • Небольшая команда ООП разработчиков

  • Протипирование игры / минимальная версия продукта (MVP)

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

OOP

COP

ECS

Atomic

игровые объекты имеют абсолютно разные структуры и поведения или абсолютно одинаковы

игровые объекты можно представить в виде набора модульных компонентов

на сцене очень много игровых объектов, которые имеют разнообразные данные и механики

игровые объекты имеют разнообразные данные и механики

оптимизация не критична

оптимизация не критична

оптимизация критична

оптимизация не критична

P.S. Если будет желание поделиться вашим мнением насчёт подходов, пожалуйста напишите в комментариях. Я по возможности постараюсь ответить на них)

На этом у меня все! Благодарю за внимание :)
В качестве материалов прикрепляю ссылки на библиотеки: Elementary, Declarative, Entity

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


  1. Ar3sss
    26.05.2023 16:50
    +1

    Я конечно не unity-разработчик (бэкенд), но есть вопрос , а нельзя было в ООП подходе использовать интерфейс IMovable например и решать все выше перечисленние проблемы?


    1. StarKRE Автор
      26.05.2023 16:50

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


      1. Ar3sss
        26.05.2023 16:50

        Ну ведь для каждого обьекта нужно реализовать например метод Move() и пусть у каждого будет своя реализация со своими аспектами.


        1. StarKRE Автор
          26.05.2023 16:50

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


          1. Artemko_one
            26.05.2023 16:50

            В таком случае можно сделать один компонент и инжектить в него анимацию, с которой он будет работать. Как в случае сериализации в инспекторе, так и не-монобех класс можно проинициализировать в конструкторе


  1. SH42913
    26.05.2023 16:50
    +1

    ECS: на сцене множество игровых объектов, которые имеют схожие данные и механики

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


    1. StarKRE Автор
      26.05.2023 16:50

      Да, тут ты прав, я это и имел ввиду)

      А по поводу оптимизации, считаю, что это критически важно для крупных проектов)


    1. semenyakinVS
      26.05.2023 16:50

      В чём ключевое архитектурное преимущество ECS относительно обычной компонентной системы?


      1. StarKRE Автор
        26.05.2023 16:50

        Ни в чем. У каждого подхода есть свои преимущества и недостатки. Я это написал в разделах про компонентно-ориентированное программирование и Entity Component System.


      1. Aywi
        26.05.2023 16:50
        +1

        Зачастую внутри компонента реализуется одновременно и логика и данные, для того чтобы эта логика могла обрабатываться. Для компонента movable нужно указывать speed, ссылку на двигаемый объект и логику поведения. Для компонента attack нужны value и, бывает, speed. И т.д.

        Если говорим конкретно про Юнити реализацию компонентов, то они также всегда тянут за собой целый ненужный пласт логики и данных наследуемого класса MonoBehaviour. В данном случае это всегда минус перфоманс.

        ECS паттерн позволяет целиком отделить логику обработки данных от самих данных. К примеру возьмём movable компонент. В ECS мы разбиваем его на целых три составных части: MoveSystem, MovableComponent и ViewComponent. В MoveSystem мы будем сортировать все сущности с нужными нам компонентами и просто выполнять логику, обрабатывая данные. Как уже говорилось в статье - это полезно для перфоманса, т.к. мы имеем быстрый доступ к данным, а также выходит относительно просто реализовать многопоточность.

        В дальнейшем MovableComponent можно будет использовать в, например, MoveAnimationSystem для изменения скорости анимации в зависимости от показателя скорости. Или тот же ViewComponent в ChangeModelSystem для смены модели персонажа. В этом и раскрывается гибкость паттерна.

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


        1. semenyakinVS
          26.05.2023 16:50

          Большая часть описанных преимуществ связаны с быстродействием. А мне вот интересны архитектурные преимущества ECS-подхода. Насчёт того что MovableComponent можно использовать в нескольких системах - это же можно делать (и, собственно, делается) с компонентами из компонентно-ориентированного подхода. Компоненты часто влияют на другие компоненты.


          1. kipar
            26.05.2023 16:50
            +2

            Я так это понимаю - компоненты со временем обрастают кодом, что затрудняет рефакторинг и модификацию. В ECS код вынесен в отдельные системы которые связаны только через компоненты.

            Пример:

            Мы сделали компонет MoveComponent, сделали FlyingComponent, сделали ShootComponent. Сущность может обладать любой комбинацией из них (могут быть летающие и стреляющие но неподвижные башни и т.д.). На этой стадии у ООП-подхода уже возникли бы проблемы, но в компонентном подходе всё ок. Дальше мы начинаем писать код мезаник. FlyingComponent влияет на движение (мы игнорируем препятствия), FlyingComponent влияет на ShootComponent (скажем, стрельба чаще мажет по летающим целям), в коде стрельбы появляются ссылки вида

            if(target.Has<FlyingComponent>())...

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

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

            А в случае ECS - вся логика изначально была разбита на небольшие системы, если мы что-то меняем - в идеале мы просто дописываем пару систем и добавляем флаг-компонент. Конечно, без рефакторинга там тоже не обойтись, но он по ощущениям идет "проще" - надо разбить компонент - разбили (он ведь не включает кода), надо поменять только стрельбу по летающим - поменяли одну систему не трогая компоненты. Если хотим переиспользовать в другом проекте - компоненты можем перетаскивать смело, а из систем выбираем то что нам подходит.


            1. semenyakinVS
              26.05.2023 16:50
              +1

              в коде стрельбы появляются ссылки вида

              Так такая же ссылка появится в ECS - в соответствующей системе.

              придется тащить с собой FlyingComponent или вычищать из кода ссылки на него

              Это при плохом дизайне. При хорошем происходит настройка внешним для FlyingComponent и ShootComponent "управляющим/склеивающим" компонентом (компонентом в Unity, в Unreal - такая логика будет в Actor-е) описывающем специфическую для конкретного объекта логику.

              надо разбить компонент - разбили

              Компонент не включает, но включают все зависимые от него системы.

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

              Опыт показывает что на практике лучше делать логику более простой в понимании и в переделке - пусть при этом она и будет более "жёсткой". Добавлять гибкость и поддерживать переиспользование имеет смысл лишь тогда когда возникает очередная практическая ситуация в которой переиспользование реально требуется. В противном случае часто можно нагородить абстракций в коде пытаясь угадать направление развития игры, с вероятностью 90% не угадать и после переписывать эту "гибкость" в новом виде, подгоняя под то что есть.


              1. kipar
                26.05.2023 16:50
                +1

                Так такая же ссылка появится в ECS - в соответствующей системе.

                Да, в короткой системе которая состоит из пяти строк (пройти по компонентам и накинуть компонент урона). А скорее добавится новая система (пройти по компонентам и срезать урон вдвое). И новые проверки тоже будут добавлять или менять одну систему.

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

                Это при плохом дизайне.

                Я не спорю что компонентный подход может справиться с трудностями. Да что там, и на чистом ООП пишут игры и как-то справляются. Но с ЕЦС забот о хорошем дизайне становится меньше.

                Опыт показывает что на практике лучше делать логику более простой в понимании и в переделке - пусть при этом она и будет более "жёсткой".

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

                А с другой - "волшебство" ЕЦС как раз в том, что столь нужная гибкость получается автоматически, ее не надо закладывать. Да, добавляется некоторый бойлерплейт - для каждой фигни типа "персонаж подпрыгнул и находится в воздухе" надо создать компонент, а для каждой мелкой механики типа "когда он подпрыгнул то не может стрелять" - новую систему. А без ЕЦС обошлись бы парой строчек (булевый флаг в одном месте и проверка в другом). Но за исключением этого ни о какой расширяемости в будущем и абстракциях думать не надо - просто делаешь как можно проще.

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


        1. SH42913
          26.05.2023 16:50

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

          Наверняка вы описываете свой опыт работы с DOTS, но не нужно этот опыт распространять на ECS вцелом. ECS != DOTS. Множество других фреймворков предлагают куда более простые решения для банальных вещей.


      1. SH42913
        26.05.2023 16:50

        Ключевое отличие ECS - строгое разделение данных и логики. Это позволяет получить более гибкую и расширяемую архитектуру, особенно если хочется комбинировать разные механики. Рекомендую познакомиться с моей статьей, где я описываю преимущества и недостатки ECS.


  1. ilitaiksperta
    26.05.2023 16:50
    +1

    Не очень понятно зачем оно надо в простых казуалках. Тот же ecs используют впервую очередь изза перфоманса, который в казуалках не нужен. А типикал ООП-подход (херня с компонентами в юнити посути тот же ООП) для игр наверное лучший кейс использования самого ООП вообще. Так хорошо, как для игр ООП больше никуда не подходит, ну может для ui фреймворков.


    1. SH42913
      26.05.2023 16:50

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


  1. NaxelDev
    26.05.2023 16:50
    +1

    Кажется сравнение ООП (парадигмы) с COP, ECS, Atomic (паттернами) - лишнее. Т.к. ООП - это про сущности, как объекты, со своим поведением или состоянием, а COP/ECS/Atomic - это про организацию этого самого поведения или состояния. Немного вводит в заблуждение =)


    1. StarKRE Автор
      26.05.2023 16:50

      ООП подход тут рассматривается в качестве организации кода таким образом, что для каждого игрового объекта делается свой класс


  1. SadOcean
    26.05.2023 16:50
    +1

    Мне кажется тут есть концептуальная проблема в понятиях.

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

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


  1. redhurt96
    26.05.2023 16:50

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


    1. StarKRE Автор
      26.05.2023 16:50

      Этот подход можно использовать в играх, где в каждом кадре выполняется не слишком много операций, например пошаговка)


  1. arTk_ev
    26.05.2023 16:50

    Неграмотная статья, и еще и вредная.

    1) Какое еще ооп-проектирование в 2023?

    2) Непонимание что такое слой модели.

    3) Непонимание что такое внедрение зависимости.

    4) Непонимание что такое архитектура

    5) Непонимание ECS


    1. StarKRE Автор
      26.05.2023 16:50

      Ок ????