
За всю карьеру удалось поработать на множестве игровых проектов в качестве Lead Unity Developer. И за более чем 5 лет удалось насмотреться на разные подходы к созданию проектов.
В данной статье я собрал выжимку статей и мыслей из своего блога о подходах, которые я использую в своих проектах. А так же поделился шаблоном пустого проекта, в котором отражена самая удобоваримая архитектура проекта в unity.
Не волнуйтесь, никаких SOLID и других изотерических практик. Коротко, по делу, подкрепленное использованием на нескольких проектах.
TL;DR
Просто можете почитать readme с моего шаблона пустого проекта, там все по структуре понятно будет ❤️
Об авторе
Меня зовут Алексей и я Lead разработчик. А последние полгода помогаю компании Zeptolab улучшать проект Overcrowded.
Я самоучка. Всё, что связано с разработкой игр, я изучал самостоятельно. Все мои знания - личный опыт. Работал над 5 мобильными проектами в разных жанрах (mid-core, simulator, merge-3). А также делал несколько проектов в дополненной реальности.
Проводя много времени за ответом на вопросы в unity чатиках в Telegram, я понял что тема архитектуры очень плохо освещена. А странные best practices от unity часто не имеют ничего общего с реальными проектами.
Потому сделал свой блог в Telegram, где пишу про архитектуру проектов на unity, присоединяйтесь!
(А|а)рхитектура
Общество движется вперед путем увеличения числа операций, которые может осуществлять, не раздумывая над ними.
Архитектурные подход�� (паттерны) в разработке ПО универсальны. Их можно применить к любому языку и проекту. Код, следующий этим принципам, будет +- одинаков с точки зрения масштабируемости и сложности.
Для меня архитектура проекта — это совокупность решений в организации проекта, обеспечивающие удобство работы и масштабируемость, а так же замедление роста когнитивной сложности.
И при проектировании нужно вырабатывать эти решения (правила, подходы, гайдлайны) не только для кода, но и для всего с чем проект связан:
Именование файлов и папок, структура хранения ассетов, структура сборок
Утилиты для разработки, тестирование, работа с системой контроля версии
Билд проекта и его деплой, работа со сторами и выливка хотфиксов
Мониторинг метрик, ошибок и реагирование на них
Структура проекта

Папка проекта
Перед тем как написать первую строчку кода, нужно решить очень важную проблему — быстрое захламление иерархии проекта. Чтобы последующие N-лет работать с ней без головной боли.
Обычно происходит как:
Cоздается проект, в этой же папке создается git репозиторий.
У вас плане backend с общей shared частью (это общий код для бэка и клиента, например модели). И вам нужно где-то хранить скрипты для копирования shared части.
Или бэкенд лежит в одном репозитории с клиентом.
Или CI/CD пайплайн потребует запуска кастомных скриптов.
Или есть внешние конфиги, которые вы хотите хранить вне папки Assets, чтобы они не попали в билд (например конфигурации сборки билда).
+ unity генерирует кучу папок, которые не попадают в репозиторий, но есть локально (Library, Debug, Temp, obj и прочие).
+ файлы, которые генерирует Rider и VS.В итоге все смешалось.
Что принадлежит проекту, что unity, что утилитам... Различить становится сложно
Сталкивались с таким? В общем это ведет к тому что работать с этим становится все сложнее и сложнее со временем.
Как этого избежать?
У себя на проектах я делаю такую иерархию:
+-- %project_name%
| +-- .git
| +-- %project_name%.Build
| +-- %project_name%.ExternalConfigs
| +-- %project_name%.ThirdParty
| +-- %project_name%.Keystore
| +-- %project_name%.Backend
| +-- %project_name%.Kubernetes
| +-- %project_name%.Unity
| | + -- Assets
| | | +-- _ProjectПлюсы:
Unity проект отображается в Unity Hub адекватно
Внешние и внутренние файлы разделены
Декларативность == низкая когнитивная сложность
Минусы:
Как по мне их нет, но если вы нашли, 👨💻 в комментарии
Папка _Project
Поскольку нет единого гайдлайна для именования и структуры папок, то со временем, папка Assets тоже превращается в помойку. И любой импортированный в проект пакет ассетов может наследить в иерархии так, как ему вздумается, создавая сколько угодно своих папок в корне.
Со временем когнитивная сложность растет и поиск нужной папки отнимает все больше и больше времени.
Отдельная папка выделяет место чисто под файлы вашего проекта. Вы можете выстроить свою иерархию, в которую ни один из импортируемых ассетов не влезет.
Внутри _Project делаю такую иерархию:
+-- _Project
| +-- Art
| +-- Develop
| +-- Plugins
| +-- Resources
| +-- Scenes
| +-- прочие папкиПлюсы:
Быстрая адаптация новых людей
Легкая масштабируемость
Поскольку наши файлы и файлы импортируемых ассетов лежат отдельно, обновление последних происходит без головняка и без риска задеть наши файлы
Минусы:
Глубокая иерархия - увеличивает когнитивную нагрузку
Требуется порядок и правила создания новых папок - чтобы со временем не стало помойки
Некоторые папки типа StreamingAssets нельзя скопировать к себе в _Project
Сцены

Первая проблема, которую я сразу решаю на новом проекте — 4 сцены для управления состоянием проекта.
0.Bootstrap— точка входа и контекст проекта (аналитика, SDK сторов, платежка, конфиги, связь с сервером)1.Loading— загрузка, авторизация, GDPR, проверка обновлений и прочее что нужно для старта игры.2.Meta— в основном UI в котором вы тратите накопленные ресурсы на прогресс по игре3.Core— основная реиграбельная часть4.Empty— костыль сцена для 100% выгрузки всех ресурсов предыдущей сцены.
Из любой сцены мы можем перейти в любую. Исключение Bootstrap. В нее входим только при запуске игры. И из нее загружаем только Loading
DI

DI (Dependency Injection) — подход, который абстрагирует создание объекта, выделяя его на отдельный слой и отделяя от основного приложения.
Добавляет концепцию управления зоной использования (scope) и временем жизни объекта.
При том важно разделять:
Composition Root (CR) — паттерн, подход при котором создание объектов системы происходит в одном месте.
Выделяет 3 (RRR) фазы управления жизненным циклом объектов:
Register — создание зависимостей
Resolve — решение графа зависимостей
Release — удаление, освобождение объекта
DI container (DIc 🍆) — как правило плагин, реализация-автоматизация CR.
Т.е. вместо ручного создания и решения графа зависимостей, он через рефлексию или кодогеном сам прокидывает зависимости в конструктор или поля.
Service Locator (SL) — создает, хранит и отдает по требованию любое множество зависимостей.
Может быть частью реализации CR, но тогда решать граф нужно самому.
В этом месте, при не понимании RRR, возникает куча проблем (кольцевые зависимости, классы боги) из-за чего SL называют антипаттерном.
Из этого следует:
Реализовать DI можно без использования DIc
DIc (Zenject, Vcontainer и др.) сильно расширяют жизненный цикл любого класса
С DIс мы делегируем обязанность по созданию и управлению жизненным циклом объекта!
Легковестной реализацией DI может быть SL (статический класс со словарем)
При использовании DIc или SL важно навсегда запомнить:
Никакой логики в конструкторе. Только агрегация и композиция
Вся логика инициализации, как отдельная стадия создания объекта, выделяется в отдельный метод
В случае несоблюдения мы рискуем обратиться к зависимости раньше, чем был решен граф, что сразу == куча головняка с порядком регистрации зависимостей.
Точка входа

Точка входа — LUCA проекта, место откуда все начинается.
А ее первостепенная и единственная задача — прозрачная инициализация проекта/сцены.
Критерии прозрачности:
Можно легко найти в проекте/сцене
Объект всегда находится вверху иерархии сцены
На сцене объект всегда называется
EntryPoint
Сквозное единое именование
Постфикс Scope — для RRR фазы
Постфикс Flow — для фазы инициализации
Пример: BattleScope, BattleFlow, MetaScope, MetaFlow
Единый дизайн классов
Каждый Scope наследуется от класса, предоставляющий функционал IoC контейнера LifetimeScope, MonoInstaller
Каждый Flow начинается с метода Start, в котором происходит вся инициализация
Т.е. мы сначала создаем объекты, inject'им их в Flow, ждем вызова Start и согласно нашему порядку инициализируем.
Из интересного:
Поскольку решение графа и инициализация разделены, то начало работы класса может быть отложено на сколько угодно по времени.
Инициализация в Start освобождает от необходимости помнить что такое
ExecutionOrderТак же защищает от рисков обратиться к внешней зависимости (плагину), которая еще не проинициализирована.Можно легко включать, отключать части игры, занося блоки инициализации под define
Правило "1 точка входа = 1 сцена" избавляет от racing condition при инициализации
Для инициализации монобехов пишется отдельный метод Init
Start и Awake не реализуются из-за racing condition
Иногда удобно прописать вызовInit()вStart(), чтобы запустить объект на другой сцене, вне основной системыЛегко сделать примитивный перезапуск игры. Достаточно ещё раз вызвать метод Start
Define'ы проекта
В большинстве случаев, делай как удобно и норм.
Но со временем у меня сформировалось 3 подхода к добавление и использованию define’ов в проекте:
Восходящий — define’ы и их кол-во определяется фичами, которые используются в проекте. Как бы идем снизу, от фичей.
Начинаются с ENABLE или DISABLE и отключают какой-то функционал, например:DISABLE_CHEATS,DISABLE_LOGS,ENABLE_SERVER_CONFIGSи т.д.Нисходящий — define’ы и их кол-во определяется потребностями проекта.
Начинаются с имени компании SILVERFOX, ROGAIKOPbITA и отключают/включают целую группу функций, которые должны быть доступны на том или ином окружение.Комбинированный — когда включение одного define’а, включает/отключает несколько фичей. Требует конфигурации глобальных предиктив (те что действуют на весь проект).
К сожалению поддерживается только для проектов, где *.csproj файлы задаются пользователем. В unity эти файлы генерирует только IDE, самому unity они не нужны, т.к. файлы ищутся по всему проекту и отдаются на сборку через csc.exe.
Например вот так в своей студии я организовывал define’ы:
SILVERFOX_RC— отключены читы, отключен fake iap магазин, отключены валидации данных, отключены все логи ниже warning, включена консоль с логамиSILVERFOX_PROD— отключены все фичи отладки и разработкиВ случае если ни одна из предиктив не указана, проект собирается со всеми включенными отладками, читами, логами. В общем работает по максимуму на разработчика и поиск проблемы
Плюсы:
Кол-во define’ов полностью синхронизировано с кол-вом веток и их назначением
Есть четкое понимание когда какой define нужно использовать
Нет проблемы с двойным отрицанием по типу !DISABLE_CHEATS
Минусы:
Как по мне их нет, но если вы нашли, 👨💻 в комментарии
Структура сборок

Сборки (Assembly definition, asmdef) — unity обертка над сборками в .NET. По факту это json в котором прописана конфигурация .NET Assembly.
Это делает сборки неотъемлемой частью архитектуры, потому что каждую сборку можно версионировать, проставлять в нее зависи��ости, определять отдельные define’ы.
Так же по умолчанию она поддерживает проверку на циклические зависимости.
У себя на проектах я выделяю такие сборки:
Runtime— клиентский кодEditor— утилиты редактораTests.PlayMode— интеграционные тесты.Т.е. тесты, для которых требуется unity scripts lifecycle
Tests.EditorMode— unit тесты
Для онлайн приложений так же выделяю:
Transport— cпецифичная для клиента связь с сервером. Реализация подключения, обмена данными, переключение состоянийShared— общие с бэком модели данных, константы, конфиги и прочее.Т.е. эти сборки является моделью домена и явно отделяют отображение unity (слой Presentation) от источника данных (слой Data Source)
Выделение этих частей позволяет mock'ать клиентскую часть и запускать клиент без unity т.е. должны стоять галки No Engine References
Из интересного:
В define constrains поддерживает отрицание. Т.е. можно выключить всю сборку для PROD define’ов
MonoBehaviorнельзя добавить как компонент на объект если в Platforms отмечен только Editor
Чтобы это обойти, поставьте галку на любую платформу, под которую 100% проект никогда не будет собран (Stadia например)Для удобства редактирования и отслеживания изменений в git, уберите галку Use GUIDs
csc.rspможно положить рядом с asmdef и прописать туда дополнительные параметры компиляции
Удобно когда нужно подключить большой список define’ов или отключить надоедлив��е warning’иМожно делать точеные оптимизации или патчи IL кода через Mono.Cecil или Harmony. Так раньше был сделан Inject в VContainer
Если сборка не изменилась, то перекомпилироваться она не будет. Таким образом, разбивая крупные сборки на более мелкие, можно снизить время компиляции проекта
Инициализация
Для правильной инициализации нужно одно:
❕Честная загрузка приложения без фейковых ожиданий и шкал.
Это меняет архитектуру объектов, так как загрузка должна быть асинхронной и последовательной.
Звучит не сложно, но часто проект сталкивается с проблемами из-за невнимания к этому:
Инициализация происходит слишком рано
У меня раньше было искажение что инициализация должна происходить до того как загрузится сцена. Такое решение может привести к неочевидным проблемам.Инициализация детерминирована
Когда RRR и инициализация разделяются, но вызовы методов не упорядочены
Или упорядочены не прозрачно, с завязкой на порядок регистрации илиBindExecutionOrderЛогика инициализации выполняется раньше решения графа
Частая ошибка - писать весь код инициализации в Start или Awake, смешивая загрузку и инициализацию ресурсов.Так можно столкнуться с ситуацией, когда ресурс А зависит от ресурса Б, который еще не готов.
Чтобы этих проблем не было, нужно:
Разделить фазу RRR и инициализации
Выделить конcтруктор и метод Init(-ialize)
Сделать сервис загрузки
Реализовать все Initialize методы
Запустить инициализацию, когда все компоненты движка 100% загружены
Тут лежит пример как это может быть реализовано на практике.
Это реальное тестовое задание, которое я делал пару месяцев назад меня с ним взяли в компанию, где я сейчас работаю.
Так же пример точки инициализации с одного из реальных проектов. Для понимания как это выглядит в продакшене.
Unity Empty Project Template (UEPT)
По всем описанным выше пунктам я решил сделать шаблон пустого проекта, в котором реализованы все описанные в данной статье пункты.
В разделе Installation вы сможете найти инструкции по настройке.
Пока там 10 пунктов, все из которых нужно делать ручками.
Использовать unitypackage не получилось т.к. он игнорирует пустые папки при импорте 😵💫
Заключение
Все перечисленные блоки носят лишь рекомендательный характер. Скорее всего на реальных проектах вы встретите лишь часть от описанного выше. А это значит, что есть что добавить в бэклог и сделать взаимодействие с проектом чуточку лучше.
На этом все, но если хочется почитать обо мне, моем пути в IT, опыте создания своей игровой студии, буду рад вашему присоединению в мой блог в Telegram.
