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-контейнеры — это идеология и правила, как ими пользоваться, чтобы на самом деле управлять зависимостями.

????? Clear Architecture ? Game Contexts. ??????? SVG.
Открыть в полном размере

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

Сервисы одного контекста тоже выстроены в определённом порядке инициализации. Например, в Game Global Context он соответствует расположению сервисов на схеме сверху вниз: Logs → Analytics → GameSettings → GameResources → GameAudio → GameManager. Здесь этот порядок важен. В других контекстах сервисы более независимы.

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

02 Где здесь Clean Architecture Роберта Мартина

Если сравнить схему контекстов с кругами Clean Architecture, может показаться, что архитектура вывернута наизнанку. Вложенные игровые контексты используют внешние, а у Роберта Мартина зависимости направлены внутрь. Где же здесь чистая архитектура?

В исходном описании Clean Architecture Роберт Мартин помещает в центр сущности, а общие бизнес-правила, вокруг них. Главное правило: внутренний код ничего не знает о внешних слоях, которые его используют.

В моём подходе такая структура находится внутри отдельных сервисов, Brick и ToY.

Внутри сервиса профиля игрока

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

  1. Сервис профиля — верхний слой, который принимает запрос и выполняет нужную команду.
  2. Команды — слой ниже: реализуют операции, которые изменяют данные профиля.
  3. Данные профиля — состояние игрока, которое ничего не знает о командах и сервисе, управляющих его изменениями. Оно может опираться на определения из игровых конфигов.
  4. Конфиги — самый нижний статический слой: определения и параметры игры. Они ничего не знают о профиле, командах и сервисе.

Сервис знает о командах, команды — о данных, данные — о необходимых им конфигах. Обратных зависимостей нет. Именно это разделение сохраняет независимость внутренних слоёв.

Внутри Brick или ToY

Тот же подход работает для игрового объекта:

  1. View — отображает состояние объекта и обновляет его визуальное представление.
  2. Системы объекта или код компонента — меняют его состояние, но ничего не знают о том, кто их вызывает и в каком управляющем сценарии они работают.
  3. Состояние объекта — хранит текущие данные и ничего не знает о том, как и кем они изменяются.
  4. Конфиги — задают статические параметры объекта и ничего не знают о расположенных выше слоях.
Clean Architecture inside modules. Open SVG.
Открыть в полном размере

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 пользуется сервисами движка.

Все темы Flexy.Bricks\ToYs