01 Контексты снаружи и внутри игры
Даже самый простой HelloWorld в Unity запускается не в пустоте, а уже внутри контекста. Уже работает операционная система, полностью инициализирован .NET Runtime, запущены сервисы Unity.
И первая строка кода уже может всем этим пользоваться, потому что она выполняется внутри этого контекста:
Debug.Logвыведет сообщение в систему логирования;SceneManager.LoadSceneзагрузит указанную сцену;Resources.Loadзагрузит ресурс с диска;CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCultureзадаст культуру по умолчанию;- и так далее.
По сути, мы всегда запускаемся уже внутри стека контекстов: Operation System → .NET → Unity → наша игра.
Идея контекстов продолжает этот же способ организации на уровне игры. За OS, .NET и Unity следуют Game(Global), Boot, Menu, Play, Map... Внешние контексты ничего не знают о внутренних. Игровые сервисы становятся таким же доступным окружением для объектов, какими сервисы движка уже являются для MonoBehaviour.
Контекст не заменяет DI-контейнеры — это идеология и правила, как ими пользоваться, чтобы на самом деле управлять зависимостями.
Порядок инициализации следует этой последовательности. Сначала запускаются внешние контексты, затем на их основе — вложенные. Если объект заспавнился во внутреннем контексте, это гарантирует, что внешние готовы и к их сервисам можно обращаться.
Сервисы одного контекста тоже выстроены в определённом порядке инициализации. Например, в Game Global Context он соответствует расположению сервисов на схеме сверху вниз: Logs → Analytics → GameSettings → GameResources → GameAudio → GameManager. Здесь этот порядок важен. В других контекстах сервисы более независимы.
В таком подходе мы не пытаемся контролировать, какие сервисы из контекста использует отдельно взятый объект: это всё равно не работает. Мы контролируем сами контексты, состав их сервисов и то, какой объект в каком контексте живёт. Объект сам получает из своего контекста всё, что ему нужно, а мы считаем, что он условно зависит от всего контекста целиком.
02 Где здесь Clean Architecture Роберта Мартина
Если сравнить схему контекстов с кругами Clean Architecture, может показаться, что архитектура вывернута наизнанку. Вложенные игровые контексты используют внешние, а у Роберта Мартина зависимости направлены внутрь. Где же здесь чистая архитектура?
В исходном описании Clean Architecture Роберт Мартин помещает в центр сущности, а общие бизнес-правила, вокруг них. Главное правило: внутренний код ничего не знает о внешних слоях, которые его используют.
В моём подходе такая структура находится внутри отдельных сервисов, Brick и ToY.
Внутри сервиса профиля игрока
Например, сервис профиля можно разложить на следующие слои. Здесь и далее перечисление идёт от управляющего кода к данным, от которых он зависит:
- Сервис профиля — верхний слой, который принимает запрос и выполняет нужную команду.
- Команды — слой ниже: реализуют операции, которые изменяют данные профиля.
- Данные профиля — состояние игрока, которое ничего не знает о командах и сервисе, управляющих его изменениями. Оно может опираться на определения из игровых конфигов.
- Конфиги — самый нижний статический слой: определения и параметры игры. Они ничего не знают о профиле, командах и сервисе.
Сервис знает о командах, команды — о данных, данные — о необходимых им конфигах. Обратных зависимостей нет. Именно это разделение сохраняет независимость внутренних слоёв.
Внутри Brick или ToY
Тот же подход работает для игрового объекта:
- View — отображает состояние объекта и обновляет его визуальное представление.
- Системы объекта или код компонента — меняют его состояние, но ничего не знают о том, кто их вызывает и в каком управляющем сценарии они работают.
- Состояние объекта — хранит текущие данные и ничего не знает о том, как и кем они изменяются.
- Конфиги — задают статические параметры объекта и ничего не знают о расположенных выше слоях.
03 Контекст — окружение объекта
Так контексты выстраивают программные слои игры: вложенный слой использует уже работающие внешние сервисы. Например, игровые объекты на карте (ToYs) обращаются к общим игровым данным. При этом у каждого из двух игроков есть свои Input, камера и HUD(Brick).
Вложенность контекстов определяет, какие сервисы доступны объектам и системам внутри каждого из них. Почти все DI-контейнеры и Service Locator умеют выстраиваться в иерархию: каждый уровень иерархии и есть контекст. Базовая структура игровой иерархии выглядит так:
GlobalContext— глобальные сервисы игры, которые живут всегда.BootContext— слой, который может пересоздаваться при перезагрузке игры. Здесь находятся сервисы, которые могут загружать данные по сети для инициализации: игровые конфиги (Game Design Info), профиль игрока, а также UI-подсистема, которая уже может отображать окно загрузки игры.MenuContext— метагейм и его сервисы: покупки, реклама, социальные функции и так далее.PlayContext— контекст коргейма, который я рекомендую делать параллельно контексту метагейма. Он включает сервисы загрузки коргейма, сетевую систему, игровые системы, сохранение игры, игровой режим и UI-экраны, которые живут внутри этого контекста и могут отображать его состояние.MapContext— карта, которая хранит связи с объектами сцены. Здесь находятся сервисы, привязанные к этим объектам: точки спавна, точки интереса, навигационная система и другие.PlayerContext— отдельный контекст игрока имеет смысл, если игра должна поддерживать split-screen. Подробнее об этом ниже. Для двух игроков создаются два таких контекста.
Этого набора контекстов достаточно почти для любой игры. Иногда их лишь немного перестраивают под нужды проекта — например, помещают PlayContext внутрь MenuContext.
В Unity контексты лучше всего привязывать к сценам. Появляясь, объект всегда оказывается на сцене, которая и становится его естественным окружением — контекстом его существования. По сцене объект может найти контекст, в котором находится. Так объект не может случайно оказаться вне контекста: он сам находит своё окружение и корректно работает. Даже если просто перетащить объект из проекта на сцену, он окажется в нужном контексте.
Выводы
Итого: каждый модуль — сервис, Brick или ToY — внутри устроен по правилам Clean Architecture. При этом он живёт в игровом контексте, из которого его логика может получить все необходимые ей сервисы. Так Game Contexts расширяют чистую архитектуру Роберта Мартина для геймдева: сохраняют её правила внутри модулей и задают окружение для их работы и взаимодействия.
04 Split-screen и мини-игра
Split-screen
При split-screen глобальный поиск камеры или HUD неоднозначен. Два PlayerContext позволяют использовать одинаковый код для обоих игроков: каждый объект получает сервисы своего игрока, а игровые данные остаются общими в PlayContext.
Мини-игра внутри игры
MiniGameContext можно вложить непосредственно в GlobalContext и предоставить ему собственные данные, сервисы и правила. Это позволяет запускать мини-игру без конфигурации основного мира.
05 Тестирование следует тому же порядку
Для тестирования модуля (Brick\ToY) надо запустить ОС и Unity: в вакууме их не протестировать, и тут мы все согласны. Но когда дело доходит до самой игры, часто начинаются танцы с бубном. Если использовать тот же подход и для игры, нам всего-то надо запустить игровые контексты, для жизни в которых предназначен объект, заспавнить его там и тестировать всё необходимое.
Это приводит нас к тому, что контексты должны уметь спавниться и инициализироваться синхронно — как минимум в среде разработки.
Тогда, если объект, скажем пистолет, не имеет жёстких связей с другими объектами, а зависит только от контекста, в котором должен жить, его легко тестировать в Play Mode тестах, а иногда даже в юнит-тестах.
Для удобства разработки я рекомендую иметь отдельную сцену для каждого игрового объекта, для его тестов и для самого производства и просмотра возможностей. Такая изоляция сильно мешает наделать связей, которых у объекта быть не должно, и помогает игре оставаться модульной на протяжении всего жизненного цикла.
В такой сцене есть скрипт, который синхронно поднимает все необходимые контексты до первого Awake в сцене. Тогда любые объекты сцены живут по обычным правилам Unity, без специального кода для тестирования.
06 DI-контейнеры и Service Locators
Игровой контекст по своей сути — иерархический контейнер с сервисами, то есть почти любой DI-контейнер или Service Locator подходит. Но сами по себе DI и Service Locator — лишь инструменты доступа к сервисам. Они не задают структуру программных слоёв, поэтому даже с ними легко получить сильную и неконтролируемую связанность всего со всем.
Game Context — это прежде всего подход к тому, как смотреть на игру и раскладывать сервисы по слоям. Контейнер с сервисами — лишь инструмент, который реализует этот подход. Вложенность контекстов определяет доступ к сервисам, а порядок инициализации гарантирует, что внешние слои готовы к моменту появления объектов во внутренних.
07 GameContext
На мой взгляд, многие контейнеры предлагают API, возможности которого скорее мешают удерживать эти границы, чем помогают. Поэтому я сделал свою реализацию — GameContext, часть пакета Flexy.Core. В ней оставлен минимальный набор API без возможностей, которые я считаю вредными для такой архитектуры.
Поэтому главное здесь — не способ найти или передать сервис, а согласованность слоёв с их инициализацией. Объект появляется в подготовленном контексте и пользуется его возможностями, как MonoBehaviour пользуется сервисами движка.