Разработка игр на Unity: изучение подходов к организации архитектуры
14 июня 2023 г.Привет всем. Я опытный Unity-разработчик. Решил поучаствовать в конкурсе от Hackernoon и Tatum Games. В первой статье я расскажу об архитектуре проектов разработки игр на Unity и расскажу о наиболее распространенных подходах, с которыми я сталкивался. И, конечно же, я вам расскажу, почему я такой мазохист и почему я пришел к своему любимому HMVС (HMVP).
P.S. Все, что здесь описано, является субъективным мнением. Всем нужно учитывать специфику разработки и проекта в целом, но в целом лучшая архитектура та, которой нет и которая сочетает в себе разные подходы в удобном и эффективном для команды стиле :D р>
MonoBehavior и компонентно-ориентированное программирование (COP)
Начнем с самого простого подхода, которым пользуются в основном новички. Я не хочу сказать, что этот подход плохой, просто большинство разработчиков привыкли мыслить в терминах ООП (объектно-ориентированного программирования). Правильное использование COP (компонентно-ориентированное программирование) требует несколько иного мышления. В то же время реализация COP на основе MonoBehaviour в Unity не выглядит идеальной.
Таким образом, базовый подход подразумевает, что вся игра будет построена на 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 является GameObject
Когда лучше использовать синглтон?
* Небольшие проекты * Когда управление памятью не является проблемой, и вы управляете событиями вместо пересылки прямых ссылок * Для небольших систем, например, для управления звуком или в качестве конечной точки сбора данных для систем аналитики
Теперь давайте обсудим контейнеры внедрения зависимостей.
Контейнеры DI и чертов Zenject
О-о, многие люди продвигают Zenject и реализуют зависимости с помощью контейнера внедрения зависимостей. На самом деле многие люди используют этот огромный фреймворк как обычный синглтон.
Как обычно, я видел это на проектах:
По сути, 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 и т. д.
Но пока остановимся на основных и доступных всем примерах:
Преимущества очевидны — мы отделяем управление (пользовательский ввод) от данных и от представления (то, что пользователь видит на экране). Связь обычно управляется событиями и инициализируется в контейнере приложения. Это устраняет необходимость в большой сплоченности; мы общаемся только с событиями.
Однако здесь есть некоторые недостатки:
* По мере масштабирования проекта наше приложение класса установки (тот же контейнер) растет * Горизонтальное расположение MVC создает огромное количество различных классов, слабо связанных друг с другом
Теперь давайте обсудим другой подход.
MVC в контейнерах
Еще один возможный сценарий – связать триаду MVC с контейнером внедрения зависимостей. Таким образом, мы можем лучше контролировать соединения между приложениями, но очень легко превратить все в Service Locator.
Подход отличается тем, что вместо связывания контроллеров с событиями мы разрешаем наши контроллеры через контейнер, а затем работаем с событиями. Впрочем, тут все равно возникают проблемы, как и с обычным DI-контейнером, но тут повышенная сложность вхождения и создается больше классов. Однако мы разделяем представление, модели и контроллеры.
HMVC/HMVP
Об этом я хотел бы поговорить немного дольше, так как я, как мазохист, очень полюбил этот подход. С его помощью мы создаем древовидное разделение нашего M-V-C, что дает несколько преимуществ, несмотря на сильно увеличивающуюся кодовую базу.
Итак, давайте рассмотрим схему взаимодействия, которую я использую чаще всего:
Как это работает?
Изначально мы создаем пустую сцену с помощью GameInstaller, который будет загружать контейнеры для каждой сцены отдельно. Сам класс GameInstaller хранит глобальные триады (верхнего уровня), которые обычно отвечают за большие системы (например, обработку звука), и хранит общие события для всего жизненного цикла игры.
Затем GameInstaller загружает нужный вам контейнер сцены, который инициализирует триады верхнего уровня внутри себя (например, универсальный контроллер игрока), а тот, в свою очередь, инициализирует внутри себя дочерние контроллеры (например, контроллер пушки). И так продолжается спуск. Связь всех ветвей происходит исключительно через события и динамические поля.
Звучит сложно, но все гораздо проще: такой подход позволяет легко разделить все триады, сохраняя при этом адекватную контекстуальную связь между их дочерними элементами. Инициализация каждого докладчика начинается с получения контекста событий от родителя.
Я вижу в этом подходе несколько преимуществ:
* Сцены проекта могут быть загружены почти мгновенно, а наши объекты, включая представление, могут быть инициализированы по запросу при загрузке нашего дерева. Если нам не нужно загружать представление с настройками или игровой магазин перед отправкой события, мы не сохраняем ничего, кроме события. * Плотное структурирование и обособление отдельных трезвучий * Слабая сплоченность из-за событий * Динамичность за счет событий * Довольно легко отлаживать ветки триады, а не через контейнеры
Есть несколько недостатков:
* Если вам нужно провести событие по дереву из 20 триад, это будет довольно долгая работа, но подход предполагает хороший первоначальный дизайн. * Большая кодовая база для проекта, хотя и хорошо структурированная * Если вам нужно связать ветки вместе, это может стать для вас большим испытанием — провести события через дюжину классов
В целом HMVC/HMVP нужен для хорошо организованных проектов с высокой степенью изоляции подсистем, высокими требованиями к памяти и игровым ресурсам. Но привыкание к нему может занять больше времени, чем к другим подходам.
Заключение
Каждый подход к организации проекта имеет свое место. Все зависит только от цели дизайна. Если вам нужна жесткая архитектура и быстрая работа с памятью, а также нужны быстрые и динамичные ресурсы — берите HMVC. Если вам нужно быстро прототипировать свой проект без суеты - пишите все на синглтонах.
Оригинал