Всем привет! 👋

На прошлой неделе вышла вторая версия архитектурного фреймворка Atomic, который применяет атомарный подход в разработке игр на Unity и C#.

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

В этой статье мы подробно разберём концепцию атомарного подхода и посмотрим, как можно проектировать архитектуру из «болтиков» и «винтиков». Также будет рассмотрен подход к разработке игр на Unity, основанный на единообразной архитектуре и согласованном взаимодействии между игровыми системами.


Оглавление


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

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

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

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

В основе атомарного подхода лежит авторский паттерн Entity-State-Behaviour (ESB), который состоит из сущности, состояния и поведения.

Визуализация паттерна Entity-State-Behaviour
Визуализация паттерна ESB

В Atomic всё строится вокруг сущности (Entity), которая представляет собой динамический контейнер. В контейнер складываются данные в виде атомарных элементов и логика в виде контроллеров. При этом данные и логика строго разделены между собой.

Состоянием (State) сущности является набор shared-данных, организованных в виде атомарных элементов. Каждый элемент имеет ссылочный тип и представляет собой универсальный объект в виде константы, переменной, события, действия или функций. Атомарные элементы, словно «болтики» и «винтики», напрямую добавляются в контейнер сущности.

Поведение (Behaviour) сущности представляет собой упорядоченный набор контроллеров. Они обрабатывают события жизненного цикла сущности и выполняют операции над её данными. Каждый контроллер реализует один или несколько интерфейсов жизненного цикла, например: инициализация, обновление или уничтожение.

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

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

Пример механики

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

Шаг 1. Определение данных

Для перемещения персонажа нам необходимы данные в виде позиции, скорости и направления движения.

  • Position — переменная, хранящая текущую позицию объекта.

  • MoveDirection — переменная, определяющая направление движения.

  • MoveSpeed — переменная, задающая величину скорости.

После определения конкретных типов данных, мы можем их сгенерировать с помощью специального плагина, написанного для Rider IDE. Как именно настроить кодогенерацию в проекте, поговорим в разделе «Настройка кодогенерации».

Шаг 2. Определение логики

Для перемещения персонажа нам необходимо поведение, которое на каждом кадре будет брать Position и прибавлять к нему произведение MoveDirection и MoveSpeed.

Механика перемещения сущности
Механика перемещения сущности

Напишем поведение, которое будет двигать нашу сущность в направлении движения:

public sealed class MoveBehaviour : IEntityInit, IEntityTick
{   
    // Данные в виде атомарных элементов
    private IVariable<Vector3> _position;   
    private IVariable<Vector3> _moveDirection;    
    private IVariable<float> _moveSpeed;    

    // Инициализация сущности
    public void Init(IEntity entity) 
    { 
        _position = entity.GetPosition(); // Generated method
        _moveSpeed = entity.GetMoveSpeed(); //Generated method
        _moveDirection = entity.GetMoveDirection(); // Generated method
    }    

    // Обновление сущности в каждом кадре
    public void Tick(IEntity entity, float deltaTime)    
    {        
        Vector3 direction = _moveDirection.Value;
        if (direction != Vector3.zero)
            _position.Value += _moveSpeed.Value * deltaTime * direction; 
    }
}

Шаг 3. Создание сущности

Теперь создадим сущность и добавим к ней данные в виде Position, MoveSpeed, MoveDirection и логику MoveBehaviour:

// Создаем сущность персонажа
IEntity entity = new Entity("Character");

// Добавляем данные
entity.AddPosition(new Variable<Vector3>()); // Generated method
entity.AddMoveDirection(new Variable<Vector3>()); // Generated method
entity.AddMoveSpeed(new Const<float>(3.5f)); // Generated method
 
// Добавляем логику
entity.AddBehaviour(new MoveBehaviour());

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

Простейший пример: под интерфейс IValue<T> можно подставить как реализацию Variable<T>, так и Const<T>

Абстракция данных в атомарном подходе
Абстракция данных в атомарном подходе

Шаг 4. Управление жизненным циклом

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

// Инициализируем сущность. Вызов IEntityInit
entity.Init();

// Активируем сущность для обновлений в каждом кадре. Вызов IEntityEnable
entity.Enable(); 

// Обновляем игровой объект c частотой 60 FPS, пока игра активна
while(isGameRunning)
{   
    entity.Tick(0.016f); // Вызов IEntityTick    
    await Task.Delay(16); //Ждем следующий кадр
}

// Выключаем сущность для обновлений. Вызов IEntityDisable
entity.Disable();

// Освобождаем ресурсы сущности. Вызов IEntityDispose
entity.Dispose();

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

Переиспользование механики

Паттерн ESB позволяет легко переиспользовать игровые механики без переписывания кода.

В приведённом выше примере мы реализовали механику перемещения для персонажа. Теперь если мы хотим реализовать пулю с такой же механикой перемещения, достаточно создать новый экземпляр сущности, определить данные Position, MoveSpeed, MoveDirection и подключить ту же механику перемещения.

// Создаем пулю
IEntity entity = new Entity("Bullet");

// Механика перемещения (Переиспользуем)
entity.AddPosition(new Variable<Vector3>());
entity.AddMoveSpeed(new Const<float>(3.5f));
entity.AddMoveDirection(new Variable<Vector3>());
entity.AddBehaviour(new MoveBehaviour());

// Механика столкновения (Новая)
entity.AddDamage(new Const<int>(10));
entity.AddBehaviour(new CollisionBehaviour());

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

Тестирование механики

Другое преимущество атомарного подхода — это возможность тестировать игровые механики без необходимости запускать PlayMode в Unity. Так как механики пишутся на чистом C#, то их удобно проверять стандартными фреймворками типа NUnit прямо в режиме EditMode.

public sealed class MoveBehaviourTests
{
    private Entity entity;

    [SetUp]
    public void SetUp()
    {
        entity = new Entity();

        // Добавляем данные
        entity.AddPosition(new Variable<Vector3>(Vector3.zero));
        entity.AddMoveSpeed(new Const<float>(2f));
        entity.AddMoveDirection(new Variable<Vector3>());
        
        // Добавляем поведения
        entity.AddBehaviour(new MoveBehaviour());

        // Активируем сущность
        entity.Init();
        entity.Enable();
    }

    [Test]
    public void Tick_WithZeroDirection_DoesNotMove()
    {
        // Arrange
        entity.GeMoveDirection().Value = Vector3.zero;
        
        // Act
        const float deltaTime = 1;
        entity.Tick(deltaTime);

        // Assert
        Assert.AreEqual(Vector3.zero, entity.GetPosition().Value);
    }

    [Test]
    public void Tick_WithNonZeroDirection_MovesCorrectly()
    {

        // Arrange
        entity.GetMoveDirection().Value = Vector3.forward;
        
        // Act
        const float deltaTime = 0.5f;
        entity.Tick(deltaTime);

        // Assert
        Assert.AreEqual(new Vector3(0, 0, 1), entity.GetPosition().Value);
    }
}

Таким образом, разработчику больше не нужно каждый раз переключаться из IDE в Unity. Он может прямо в Rider реализовывать механики, писать изолированные тесты и запускать их через Test Runner.

Адаптация к новым требованиям

Игровые механики часто меняются по ходу разработки. Паттерн ESB обеспечивает гибкость и позволяет адаптировать сущности к новым требованиям без переписывания существующего кода.

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

public sealed class KinematicMoveBehaviour : IEntityInit, IEntityTick
{   
    private Rigidbody _rigidbody;
    private IVariable<float> _moveSpeed;
    private IVariable<Vector3> _moveDirection;

    public void Init(IEntity entity)
    {
        _rigidbody = entity.Rigidbody();
        _moveSpeed = entity.GetMoveSpeed();
        _moveDirection = entity.GetMoveDirection();
    }

    public void FixedTick(IEntity entity, float deltaTime)
    {
        Vector3 direction = _moveDirection.Value;
        if (direction == Vector3.zero)
            return;

        float moveStep = _moveSpeed.Value * deltaTime;
        if (_rigidbody.SweepTest(direction, out _, moveStep))
            return;

        Vector3 newPosition = _rigidbody.position + direction * moveStep;
        _rigidbody.MovePosition(newPosition); 
    }
}

Теперь изменяем сущность с новой кинематической механикой:

// Создаем новую сущность сущности с именем "Character"
IEntity entity = new Entity("Character");

// Добавляем данные
entity.AddRigidbody(rigidbody); // (+)
entity.AddMoveSpeed(new Variable<float>(3.5f));
entity.AddMoveDirection(new Variable<Vector3>());

// Добавляем поведения
entity.AddBehaviour(new KinematicMoveBehaviour()); // (+)

// Убираем предыдущие компоненты
// entity.AddValue("Position", new Variable<Vector3>());
// entity.AddBehaviour(new MoveBehaviour());

Таким образом, мы просто заменяем данные Position на Rigidbody, а поведение MoveBehaviour — на KinematicMoveBehaviour, реализуя новые требования без переписывания существующей бизнес-логики.


Работа с фреймворком в Unity

В этом разделе рассмотрим использование фреймворка Atomic в Unity. Проведём настройку кодогенератора, создадим игровой объект, проанализируем развитие проекта по паттерну ESB, а также применим процедурный подход для организации взаимодействия между объектами и системами.

Настройка кодогенерации

Прежде чем приступить к созданию игровых механик, необходимо настроить процесс генерации данных. Фреймворк Atomic поддерживает автоматическую генерацию extension-методов для сущностей, что позволяет избежать хардкода, использования «магических констант» и обеспечивает безопасность типов при работе с данными.

Шаг 1. Установка плагина для Rider

Установить плагин можно через JetBrains Marketplace или через официальный репозиторйи на Github

Установка плагина Atomic через Rider IDE
Установка плагина Atomic через Rider IDE

Шаг 2. Создание файла конфигурации

  1. Правой кнопкой нажмите на нужную директорию скриптов в Rider

  2. Выберите New → Atomic File

Создание конфигурационного файла для генерации данных
Создание конфигурационного файла для генерации данных

Нажав кнопку Create, будет создан файл с расширением .atomic, в котором можно будет прописывать значения и теги.

entityType: IEntity
namespace: MyGame.Components
className: EntityExtensions
directory: Assets/Scripts
aggressiveInlining: true
unsafe: false

imports:
  System
  UnityEngine
  Atomic.Entities

tags:
  Player
  Enemy
  Projectile

values:
  Health: int
  Position: Vector3
  Damage: float

Шаг 3. Настройка файла конфигурации

Ниже приведены параметры кодогенерации, которые можно настроить:

Параметр

Описание

По умолчанию

directory

Путь вывода для сгенерированного файла

className

Имя сгенерированного класса и файла

namespace

Пространство имён сгенерированного класса

entityType

Тип сущности (может быть IEntity или пользовательский тип, унаследованный от него)

IEntity

aggressiveInlining

Добавляет [MethodImpl(MethodImplOptions.AggressiveInlining)] к extension-методам (true/false)

false

unsafe

Использует GetValueUnsafe вместо GetValue (работает быстрее, но может быть небезопасным)

false

imports

Список пространств имён (using), необходимых для генерации кода

tags

Список тегов для генерации

values

Список значений для генерации в формате Имя: Тип

Шаг 4. Генерация кода

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

Преимущества подхода с генерацией extension-методов:

  1. Отсутствие хардкода и магических констант: Код становится проще поддерживать и менее подвержен ошибкам.

  2. Безопасность: Жёсткая типизация позволяет разработчику точно понимать, с каким типом значений он работает.

  3. Простота чтения и поддержки: Вызовы методов становятся короче и понятнее, что облегчает сопровождение проекта.

  4. Ускорение разработки: Автоматические подсказки в IDE помогают работать быстрее и эффективнее.

  5. Единое рабочее окружение: Всё происходит внутри Rider, без необходимости переключаться в Unity. Это экономит время и снижает контекстные переключения.

Более подробную информацию о подключении и настройке кодогенерации можно найти в документации официального репозитория фреймворка Atomic. Для пользователей Visual Studio предусмотрена возможность выполнения кодогенерации без использования плагина.

Создание сущности

Ниже рассмотрим процесс  создания игрового объекта с механикой перемещения. Цель раздела — показать, как с нуля можно реализовать движение объекта в Unity.

Шаг 1. Создание игрового объекта

В иерархии сцены кликните правой кнопкой мыши и выберите 3D Object → Capsule для создания нового игрового объекта.

Игровой объект на сцене в Unity
Игровой объект на сцене в Unity

Шаг 2. Добавление компонента сущности

В окне Inspector созданного объекта выберите Atomic → Entities → Entity для добавления компонента сущности.

Компонент сущности в инспекторе Unity
Компонент сущности в инспекторе Unity

Убедитесь, что включены следующие галочки:

  • useUnityLifecycle — сущность обновляется вместе с циклом MonoBehaviour

  • installOnAwake — сборка сущности выполняется на Awake

Шаг 3. Генерация данных

Добавим в конфигурационный файл EntityAPI.atomic, значения Transform, MoveDirection, MoveSpeed:

entityType: IEntity
namespace: MyGame.Components
className: EntityExtensions
directory: Assets/Scripts
aggressiveInlining: true
unsafe: false

imports:
  System
  UnityEngine
  Atomic.Entities

tags:

# Добавим данные
values:
  Transform: Transform
  MoveSpeed: IValue<float>
  MoveDirection: IVariable<Vector3>

Шаг 4. Создание механики перемещения

Механика перемещения в Unity
Механика перемещения в Unity

Напишем поведение, которое будет двигать нашу сущность в направлении движения:

public sealed class MoveBehaviour : IEntityInit, IEntityTick
{    
    private Transform _transform;   
    private IVariable<Vector3> _moveDirection;    
    private IValue<float> _moveSpeed;    

    public void Init(IEntity entity) 
    { 
        _transform = entity.GetTransform(); 
        _moveSpeed = entity.GetMoveSpeed();
        _moveDirection = entity.GetMoveDirection();
    }    

    public void Tick(IEntity entity, float deltaTime)    
    {        
        Vector3 direction = _moveDirection.Value;
        if (direction != Vector3.zero)
            _transform.position += _moveSpeed.Value * deltaTime * direction; 
    }
}

Шаг 5. Создание инсталлера

Чтобы добавить данные и логику перемещения для сущности, создадим скрипт, который будет «нашпиговывать» соответствующими атомарными элементами и поведением.

public sealed class CharacterInstaller : SceneEntityInstaller
{
   [SerializeField] private Transform _transform;
   [SerializeField] private BaseVariable<float> _moveSpeed;
   [SerializeField] private BaseVariable<Vector3> _moveDirection; 

   public override void Install(IEntity entity)
   {
       // Добавляем данные
       entity.AddTransform(_transform);
       entity.AddMoveSpeed(_moveSpeed);
       entity.AddMoveDirection(_moveDirection);
       
       // Добавляем логику
       entity.AddBehaviour<MoveBehaviour>();
   }
}

Шаг 6. Настройка игрового объекта

Далее добавим компонент CharacterInstaller к нашей сущности через инспектор и настроим его.

Компонент CharacterInstaller в инспекторе Unity
Компонент CharacterInstaller в инспекторе Unity

Шаг 7. Подключение инсталлера к сущности

Для подключения CharacterInstaller к компоненту Entity перетащим его в поле SceneInstallers.

Шаг 8. Запуск персонажа

В редакторе Unity нажмите Play, чтобы проверить, что персонаж начал перемещаться.


Единообразие архитектуры

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

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

Выглядит это так:

public class GameContext : SceneEntity
{  
}

Например, для полноценного прототипа с меню и уровнями можно выделить следующие домены:

  • Игровой процесс

    • GameEntity — базовая игровая сущность, которая перемещается по сцене.

    • GameContext — хранит основное состояние игры и правила.

    • PlayerContext — хранит состояние и характеристики игрока, если игра подразумевает мультиплеер

  • Приложение

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

  • Пользовательский интерфейс

    • GameUI — элементы игрового интерфейса, такие как HUD и всплывающие окна.

    • MenuUI — элементы меню: главное меню, экраны загрузки, настройки и уровни.

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

// Игровой контекст
public sealed class GameContextInstaller : SceneEntityInstaller<GameContext>
{
    [SerializeField] private Transform _worldTransform;
    [SerializeField] private TeamCatalog _teamCatalog;
    [SerializeField] private EntityPool _bulletPool;

    public override void Install(GameContext context)
    {
        context.AddPlayers(new Dictionary<TeamType, IPlayerContext>());
        context.AddWorldTransform(_worldTransform);
        context.AddTeamCatalog(_teamCatalog);
        context.AddBulletPool(_bulletPool);
        context.AddGameOverEvent(new BaseEvent());
    }
}
// Игровой интерфейс
public sealed class GameUIInstaller : EntityInstaller<GameUI>
{
    [SerializeField] private CountdownView _countdown;
    [SerializeField] private ScoreView _score;

    public override void Install(GameUI ui)
    {   
        // Countdown
        ui.AddCountdownView(_countdown);
        ui.AddBehaviour<CountdownPresenter>();

        // Score
        ui.AddScoreView(_score);
        ui.AddBehaviour<ScorePresenter>();
    }
}
// Контекст приложения
public sealed class AppContextInstaller : SceneEntityInstaller<AppContext>
{
    [Header("Quit")]
    [SerializeField] private KeyCode _exitKey = KeyCode.Escape;

    [Header("Levels")]
    [SerializeField] private Const<int> _startLevel;
    [SerializeField] private Const<int> _maxLevel;
    [SerializeField] private ReactiveVariable<int> _currentLevel = 1;

    public override void Install(AppContext context)
    {
        // Quit
        context.AddExitKeyCode(new Const<KeyCode>(_exitKey));
        context.AddBehaviour<QuitController>();

        // Level System
        context.AddStartLevel(_startLevel);
        context.AddMaxLevel(_maxLevel);
        context.AddCurrentLevel(_currentLevel);
        context.AddBehaviour<LevelSaveLoadController>();

        // Menu
        context.AddBehaviour<MenuLoadController>();
    }
}

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

Взаимодействие между сущностями

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

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

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

Ниже приведен пример механики нанесения урона с помощью процедурного программирования:

public static class CombatUseCase
{
    public static bool DealDamage(
        IGameEntity instigator,
        IGameEntity victim, 
        IGameContext gameContext,
        int damage 
    )
    {
        IVariable<int> health = victim.GetHealth();
        if (health.Value <= 0)
            return false;
      
        health.Value = Math.Max(0, health.Value - damage);  
        victim.GetTakeDamageEvent().Invoke(instigator, damage);
        
        if (health.Value == 0)
            gameContext.GetKillEvent().Invoke(instigator, victim);
        
        return true;
    }
}

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

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

public static class CombatUseCase 
{
    // Перегруженный метод
    public static bool DealDamage(
       IGameEntity instigator, 
       Collider victim,
       IGameContext gameContext,
       int damage
    ) 
    {
       return victim.TryGetComponent(out IGameEntity target) &&
             DealDamage(instigator, target, gameContext, damage);
    }

   // Оригинальный метод, написанный ранее 
   public static bool DealDamage(
        IGameEntity instigator,
        IGameEntity victim, 
        IGameContext gameContext,
        int damage
    ) {...}
}

Далее мы можем использовать метод CombatUseCase.DealDamage в поведении пули, которое отвечает за столкновение с другими игровыми объектами (строка 37):

public sealed class BulletCollisionBehaviour : 
    IEntityInit<IGameEntity>, 
    IEntityEnable, 
    IEntityDisable
{
    private IGameEntity _entity;
    private TriggerEvents _trigger;
    private IValue<int> _damage;
    private IAction _destroyAction;
    private readonly IGameContext _gameContext;

    public BulletCollisionBehaviour(IGameContext gameContext)
    {
        _gameContext = gameContext;
    }

    public void Init(IGameEntity entity)
    {
        _entity = entity;
        _destroyAction = entity.GetDestroyAction();
        _damage = entity.GetDamage();
        _trigger = entity.GetTrigger();
    }

    public void Enable(IEntity entity)
    {
        _trigger.OnEntered += this.OnTriggerEntered;
    }

    public void Disable(IEntity entity)
    {
        _trigger.OnEntered -= this.OnTriggerEntered;
    }

    private void OnTriggerEntered(Collider collider)
    {
        //Используем статический метод для нанесения урона
        CombatUseCase.DealDamage(_entity, collider, _gameContext, _damage.Value);
        _destroyAction.Invoke();
    }
}

Поскольку метод CombatUseCase.DealDamage вызывает событие убийства цели у GameContext (строка 18), то это событие может быть обработано в LeaderboardController:

public sealed class LeaderboardController : 
    IEntityInit<IGameContext>, 
    IEntityDispose
{
    private ISignal<KillArgs> _killEvent;
    private IGameContext _gameContext;

    public void Init(IGameContext context)
    {
        _gameContext = context;
        _killEvent = context.GetKillEvent();
        _killEvent.Subscribe(this.OnKill);
    }

    public void Dispose(IEntity entity)
    {
        _killEvent.Unsubscribe(this.OnKill);
    }

    private void OnKill(KillArgs args)
    {
        LeaderboardUseCase.AddScore(_gameContext, args);
    }
}

В контроллере мы видим, что снова используется статический метод в виде UseCase (строка 22), который отвечает за обновление счёта.

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

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

Выводы

В этом разделе поговорим о недостатках и преимуществах фреймворка, а также сравним его архитектуру с ООП и ECS:

Преимущества фреймворка

1. Низкий порог входа

Начать разработку в Atomic действительно просто. Больше не нужно тратить время на организацию классов, выбирать паттерны и строить иерархии наследования. Вы просто создаете сущность, наполняете её данными в виде атомарных элементов и описываете механики взаимодействия в отдельных контроллерах. Главное — всегда следовать принципу разделения данных и логики.

2. Унифицированная архитектура

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

3. Готовые атомарные элементы

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

4. Универсальность механик

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

5. Динамичность механик

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

6. Высокая производительность

Оптимизированный доступ к данным позволяет эффективно обрабатывать сущности на каждом кадре, обеспечивая стабильную работу даже при высоких нагрузках. Кроме того, отказ от «тяжёлых монобехов» экономит память и снижает накладные расходы на управление объектами в Unity.

7. Совместимость с ООП

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

8. Минимизация Unity

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

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

Недостатки фреймворка

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

1. Отсутствие инкапсуляции

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

2. Централизация данных

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

3. Гибридность подхода

Атомарный подход сочетает концепции ООП и ECS, что даёт разработчику большую свободу в написании кода. Однако без строгого соблюдения паттерна ESB проект может быстро стать хаотичным, что усложнит поддержку и развитие в будущем.

Чтобы проект не превратился в «спагетти», рекомендуется придерживаться следующего принципа: State — это модульные объекты данных, а Behaviour — бизнес-логика, которая их обрабатывает.

Сравнение с ООП и ECS

Ниже представлена таблица, сравнивающая атомарный подход с концепциями ECS и ООП.

Критерий

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

ECS (Entity-Component-System)

ООП (Object-Oriented Programming)

Структура

Entity-State-Behaviour: сущность как контейнер данных (State) и логики (Behaviour)

Entity — идентификатор, Components — данные, Systems — логика

Классы и объекты с полями и методами

Данные

Разделены на атомарные элементы: константы, переменные, события, действия

Хранятся в компонентах, отдельно от логики

Данные и методы часто связаны в одном объекте

Логика

Behaviour реализует операции над State, переиспользуется между сущностями

Системы обрабатывают компоненты, часто оптимизированы под CPU

Методы класса оперируют собственными данными, логика распределена по классам

Переиспользуемость

Высокая, механики можно «нашпиговать» разным сущностям

Высокая, логика отделена от компонентов, полиморфизм через данные

Зависит от проектирования, часто требует наследования или полиморфизма

Тестирование

Полностью в C#, без Unity, легко писать unit-тесты

Тестирование систем возможно, но чаще зависит от ECS фреймворка

Обычно завязвано на MonoBehaviour, но если есть DI-фреймворк, то легче писать unit-тесты

Производительность

Выше среднего. Оптимизация сущностей и миров

Очень высокая, особенно при большом количестве сущностей

Средняя, зависит от количества MonoBehaviour, апдейтов и иерархий объектов

Гибкость

Высокая: легко добавлять атомарные элементы и поведения

Высокая: легко добавлять / удалять компоненты

Ограничена: изменения могут требовать переписывания классов и методов

Порог входа

Средний, так как нужно изучить атомарные элементы и понять паттерн ESB

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

Обычно низкий, так как ООП знаком большинству разработчиков


Заключение

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

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

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


Ссылка на стрим

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

Если вам интересно узнать, как разрабатывать кор-геймплей на фреймворке Atomic, рекомендую вам посмотреть стрим на моем Youtube канале. В формате лайв-кодинга и презентации продемонстрировал, как создавать игровые объекты с нуля и ответил на ваши вопросы.

Большое спасибо за внимание! ❤️


Игорь Гулькин, Senior Unity Developer

Only registered users can participate in poll. Log in, please.
Знакомы ли вы уже с атомарным подходом?
30%Да, использую его6
15%Слышал, но не работал3
55%Нет, не знаком11
20 users voted. 7 users abstained.