Разработка игр на Unity: изучение подходов к организации архитектуры

Разработка игр на Unity: изучение подходов к организации архитектуры

14 июня 2023 г.

Привет всем. Я опытный Unity-разработчик. Решил поучаствовать в конкурсе от Hackernoon и Tatum Games. В первой статье я расскажу об архитектуре проектов разработки игр на Unity и расскажу о наиболее распространенных подходах, с которыми я сталкивался. И, конечно же, я вам расскажу, почему я такой мазохист и почему я пришел к своему любимому HMVС (HMVP).

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

MonoBehavior и компонентно-ориентированное программирование (COP)

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

Every Awake(), Start(), and Update() inherently communicates with the engine through reflection and SendMessage().

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

{ 

  private Awake() 

  { 

  } 

}

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

Одиночка

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

Простой пример Singleton, который я часто вижу у младших разработчиков:

public sealed class MySingleton 
{ 
    private static MySingleton _instance = null; 
    private MySingleton() { } 
    public static MySingleton Instance 
    { 
        get 
        { 
            if (_instance == null) 
            { 
                _instance = new MySingleton(); 
            } 
            return _instance;
        } 
    } 
    public void OperationX() { } 
} 

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

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

Singleton that combines object interaction is not a good Singleton, especially when we shove direct links inside it

Итак, как узнать, является ли Синглтон злом?

* Когда он связывает всю логику в вашей игре и всем управляет * Когда прямо в него кидается куча ссылок * Когда его размер становится огромным * Когда вы уже разобрались в отладке или управлении памятью, особенно если Singleton является GameObject

Когда лучше использовать синглтон?

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

Теперь давайте обсудим контейнеры внедрения зависимостей.

Контейнеры DI и чертов Zenject

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

Как обычно, я видел это на проектах:

The container acts as a link to find dependencies

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

public class TestInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<string>().FromInstance("Hello World!");
        Container.Bind<Greeter>().AsSingle().NonLazy();
    }
}

public class Greeter
{
    public Greeter(string message)
    {
        Debug.Log(message);
    }
}

Этот подход хорош, но только до тех пор, пока все не начнет усложняться:

* DI-Container по сути такой же, как Singleton, но улучшенный, который создает привязки к самому контейнеру. * Очень часто создаются "километровые" классы установщиков, которые делают привязки зависимостей * Трудно для понимания новичками из-за большего разделения ответственности, хотя в дальнейшем это хорошо масштабируемый подход. * Сложность отладки из-за контейнеров и вездесущих привязок * Обычный DI-контейнер очень просто превратить в Service Locator

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

MVC в чистом виде

Почему в чистом виде? Потому что это достаточно легко понять. У нас есть контроллер, модель и представление для организации проекта. Но пока существует MVC, существует столько же его подтипов, сколько существует MVP, MVVM и т. д.

Но пока остановимся на основных и доступных всем примерах:

The user, when performing some action, refers to the controller (which may or may not be a MonoBehaviour)

Преимущества очевидны — мы отделяем управление (пользовательский ввод) от данных и от представления (то, что пользователь видит на экране). Связь обычно управляется событиями и инициализируется в контейнере приложения. Это устраняет необходимость в большой сплоченности; мы общаемся только с событиями.

Однако здесь есть некоторые недостатки:

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

Теперь давайте обсудим другой подход.

MVC в контейнерах

Еще один возможный сценарий – связать триаду MVC с контейнером внедрения зависимостей. Таким образом, мы можем лучше контролировать соединения между приложениями, но очень легко превратить все в Service Locator.

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

HMVC/HMVP

Об этом я хотел бы поговорить немного дольше, так как я, как мазохист, очень полюбил этот подход. С его помощью мы создаем древовидное разделение нашего M-V-C, что дает несколько преимуществ, несмотря на сильно увеличивающуюся кодовую базу.

Итак, давайте рассмотрим схему взаимодействия, которую я использую чаще всего:

Why Presenter and not Controller? Because we create a passive model and communicate with both the representation and the model.

Как это работает?

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

Затем GameInstaller загружает нужный вам контейнер сцены, который инициализирует триады верхнего уровня внутри себя (например, универсальный контроллер игрока), а тот, в свою очередь, инициализирует внутри себя дочерние контроллеры (например, контроллер пушки). И так продолжается спуск. Связь всех ветвей происходит исключительно через события и динамические поля.

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

Я вижу в этом подходе несколько преимуществ:

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

Есть несколько недостатков:

* Если вам нужно провести событие по дереву из 20 триад, это будет довольно долгая работа, но подход предполагает хороший первоначальный дизайн. * Большая кодовая база для проекта, хотя и хорошо структурированная * Если вам нужно связать ветки вместе, это может стать для вас большим испытанием — провести события через дюжину классов

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

Заключение

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


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE