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

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

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

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


Оглавление


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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.GetValue<IVariable<Vector3>>("Position"); 
        _moveSpeed = entity.GetValue<IVariable<float>>("MoveSpeed");
        _moveDirection = entity.GetValue<IVariable<Vector3>>("MoveDirection");
    }    

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

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

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

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

// Добавляем данные
entity.AddValue("Position", new BaseVariable<Vector3>());
entity.AddValue("MoveSpeed", new BaseVariable<float>(3.5f));
entity.AddValue("MoveDirection", new BaseVariable<Vector3>());

// Добавляем логику
entity.AddBehaviour(new MoveBehaviour());

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

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

// Инициализируем сущность. Вызов 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.AddValue("Position", new BaseVariable<Vector3>());
entity.AddValue("MoveSpeed", new BaseVariable<float>(3.5f));
entity.AddValue("MoveDirection", new BaseVariable<Vector3>());
entity.AddBehaviour(new MoveBehaviour());

// Механика столкновения (Новая)
entity.AddValue("Damage", 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.AddValue("Position", new BaseVariable<Vector3>(Vector3.zero));
        entity.AddValue("MoveSpeed", new BaseVariable<float>(2f));
        entity.AddValue("MoveDirection", new BaseVariable<Vector3>());
        
        // Добавляем поведения
        entity.AddBehaviour(new MoveBehaviour());

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

    [Test]
    public void Tick_WithZeroDirection_DoesNotMove()
    {
        // Arrange
        entity
          .GetValue<IVariable<Vector3>>("MoveDirection")
          .Value = Vector3.zero;
        
        // Act
        const float deltaTime = 1;
        entity.Tick(deltaTime);

        // Assert
        Assert.AreEqual(
          Vector3.zero, 
          entity.GetValue<IVariable<Vector3>>("Position").Value
        );
    }

    [Test]
    public void Tick_WithNonZeroDirection_MovesCorrectly()
    {

        // Arrange
        entity
          .GetValue<IVariable<Vector3>>("MoveDirection")
          .Value = Vector3.forward;
        
        // Act
        const float deltaTime = 0.5f;
        entity.Tick(deltaTime);

        // Assert
        Assert.AreEqual(
          new Vector3(0, 0, 1), 
          entity.GetValue<IVariable<Vector3>>("Position").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.GetValue<Rigidbody>("Rigidbody");
        _moveSpeed = entity.GetValue<IVariable<float>>("MoveSpeed");
        _moveDirection = entity.GetValue<IVariable<Vector3>>("MoveDirection");
    }

    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.AddValue("Rigidbody", rigidbody); // (+)
entity.AddValue("MoveSpeed", new BaseVariable<float>(3.5f));
entity.AddValue("MoveDirection", new BaseVariable<Vector3>());

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

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

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

Кодогенерация

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

Для наглядности ниже приведены примеры:

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

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

// Добавляем данные
entity.AddPosition(new BaseVariable<Vector3>()); //Extension метод
entity.AddMoveSpeed(new BaseVariable<float>(3.5f)); //Extension метод
entity.AddMoveDirection(new BaseVariable<Vector3>()); //Extension метод

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

Пример механики перемещения

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(); //Extension метод
        _moveSpeed = entity.GetMoveSpeed(); //Extension метод
        _moveDirection = entity.GetMoveDirection(); //Extension метод
    }    

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

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

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

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

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

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

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

О том, как подключить и настроить кодогенерацию можно найти в документации официального репозитория фреймворка Atomic.


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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.GetValue<IVariable<Vector3>>("Position"); 
        _moveSpeed = entity.GetValue<IVariable<float>>("MoveSpeed");
        _moveDirection = entity.GetValue<IVariable<Vector3>>("MoveDirection");
    }    

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

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

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

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.AddValue("Transform", _transform);
       entity.AddValue("MoveSpeed", _moveSpeed);
       entity.AddValue("MoveDirection", _moveDirection);
       
       // Добавляем логику
       entity.AddBehaviour<MoveBehaviour>();
   }
}

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

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

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

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

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

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

В редакторе 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), который отвечает за обновление счёта.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

Симуляция на C#

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

Для развертывания сущностей необходима точка входа, с которой и начинается работа всей инфраструктуры.

Точка входа

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

Выглядит это следующим образом:

public sealed class EntryPoint : MonoBehaviour
{
    private GameContext _gameContext;

    [SerializeField]
    private GameContextInstaller _gameInstaller;

    private void Awake()
    {
        _gameContext = new GameContext();
        _gameInstaller.Install(_gameContext);
    }

    private void Start()
    {
        _gameContext.Init();
        _gameContext.Enable();
    }

    private void Update() 
    {
        _gameContext.Tick(Time.deltaTime);
    } 

    private void FixedUpdate() 
    {
       _gameContext.FixedTick(Time.fixedDeltaTime);
    }

    private void LateUpdate()
    {
        _gameContext.LateTick(Time.deltaTime);
    }

    private void OnDestroy()
    {
        _gameContext.Disable();
        _gameContext.Dispose();
    }
}

Мир сущностей

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

Подключение мира сущностей в GameContextInstaller выглядит следующим образом:

[Serializable]
public sealed class GameContextInstaller : IEntityInstaller<IGameContext>
{
    public void Install(IGameContext context)
    {
        // Подключение Entity World
        var entityWorld = new EntityWorld<IGameEntity>();
        context.AddEntityWorld(entityWorld);
    }
}

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

[Serializable]
public sealed class GameContextInstaller : IEntityInstaller<IGameContext>
{
    public void Install(IGameContext context)
    {
        // Подключение Entity World
        {...}

        //Синхронизация жизненного цикла EntityWorld с GameContext
        context.WhenInit(entityWorld.InitEntities);
        context.WhenEnable(entityWorld.Enable);
        context.WhenTick(entityWorld.Tick);
        context.WhenFixedTick(entityWorld.FixedTick);
        context.WhenLateTick(entityWorld.LateTick);
        context.WhenDisable(entityWorld.Disable);
        context.WhenDispose(entityWorld.DisposeEntities);
        context.WhenDispose(entityWorld.Dispose);
    }
}

Пул сущностей

С миром мы разобрались, но у пытливого читателя возникает следующий вопрос: «Как сущности попадут в EntityWorld

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

[Serializable]
public sealed class GameContextInstaller : IEntityInstaller<IGameContext>
{
    // Каталог со всеми сущностями
    [SerializeField]
    private GameEntityCatalog _entityCatalog;

    public void Install(IGameContext context)
    {
        // Подключение Entity World
        {...}

        // Синхронизация жизненного цикла EntityWorld с GameContext
        {...}

        // Добавление мульти-пула
        var pool = new MultiEntityPool<string, IGameEntity>(_entityCatalog);
        context.AddEntityPool(pool);
    }
}

Спаун сущностей

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

Далее показан пример спауна и деспауна сущностей:

public static class GameEntitiesUseCase
{
    public static IGameEntity Spawn(
        IGameContext gameContext,
        string name,
        Vector3 position,
        Quaternion rotation,
        TeamType team
    )
    {
        // Арендуем игровой объект из пула
        MultiEntityPool<string, IGameEntity> pool = gameContext.GetEntityPool();
        IGameEntity entity = pool.Rent(name);

        // Обновляем ему позицию и команду
        entity.GetPosition().Value = position;
        entity.GetRotation().Value = rotation;
        entity.GetTeam().Value = team;

        // Добавляем игровой объект в мир
        EntityWorld<IGameEntity> world = gameContext.GetEntityWorld();
        world.Add(entity); //Автоматическая активация игрового объекта
        return entity;
    }

    public static bool Despawn(IGameContext gameContext, IGameEntity entity)
    {
        // Удаляем игровой объект из мира
        EntityWorld<IGameEntity> world = gameContext.GetEntityWorld();
        if (!world.Remove(entity)) //Автоматическая деактивация игрового объекта
            return false;

        //Возвращение игрового объекта в пул
        MultiEntityPool<string, IGameEntity> pool = gameContext.GetEntityPool();  
        gameContext.GetEntityPool().Return(entity);
        return true;
    }
}

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

Визуализация в Unity

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

Представление сущности

Для представления сущности на сцене есть класс EntityView. При включении он привязывается к Entity и добавляет в него визуальные данные и логику.

Взаимодействие Entity и EntityView
Взаимодействие Entity и EntityView

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

public sealed class CharacterViewInstaller : SceneEntityInstaller<GameEntity>
{
    [SerializeField] private TakeDamageViewBehaviour _takeDamageBehaviour;
    [SerializeField] private PositionViewBehaviour _positionBehaviour;
    [SerializeField] private RotationViewBehaviour _rotationBehaviour;
    [SerializeField] private TeamColorViewBehaviour _teamColorBehaviour;
    [SerializeField] private WeaponRecoilViewBehaviour _weaponRecoilBehaviour;

    public override void Install(GameEntity entity)
    {
        entity.AddBehaviour(_takeDamageBehaviour);
        entity.AddBehaviour(_positionBehaviour);
        entity.AddBehaviour(_rotationBehaviour);
        entity.AddBehaviour(_teamColorBehaviour);
        entity.AddBehaviour(_weaponRecoilBehaviour);
    }

    public override void Uninstall(GameEntity entity)
    {
        entity.DelBehaviour(_takeDamageBehaviour);
        entity.DelBehaviour(_positionBehaviour);
        entity.DelBehaviour(_rotationBehaviour);
        entity.DelBehaviour(_teamColorBehaviour);
        entity.DelBehaviour(_weaponRecoilBehaviour);
    }
}

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

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

[Serializable]
public sealed class PositionViewBehaviour : 
    IEntityInit<GameEntity>, 
    IEntityDispose
{
    [SerializeField] 
    private Transform _transform;
    
    private IReactiveValue<Vector3> _position;

    public PositionViewBehaviour(Transform transform)
    {
        _transform = transform;
    }

    public void Init(GameEntity entity)
    {
        _position = entity.GetPosition();
        _position.Observe(this.OnPositionChanged);
    }

    public void Dispose(IEntity entity)
    {
        _position.Unsubscribe(this.OnPositionChanged);
    }

    private void OnPositionChanged(Vector3 position)
    {
        _transform.position = position;
    }
}

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

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

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

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

Представление мира

Для отображения всех сущностей на сцене используется класс EntityCollectionView, который следит за состоянием мира и создаёт соответствующие EntityView для добавленных объектов.

Диаграмма классов
Диаграмма классов

На диаграмме видно, что EntityCollectionView принимает на вход коллекцию сущностей и управляет их визуальным представлением. У него есть метод Hide, который отключает текущую коллекцию. Когда в коллекцию или EntityWorld добавляются или удаляются сущности, EntityCollectionView автоматически спавнит и деспавнит EntityView на сцене в Unity.

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

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


Сравнение с ООП и 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, приглашаю вас на стрим 2 ноября в 19:00 по МСК на моем Youtube канале. В формате лайв-кодинга и презентации я покажу, как создавать игровые объекты с нуля и отвечу на ваши вопросы.

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


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

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


  1. Jijiki
    30.10.2025 18:32

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

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