1. Что есть игра

Игра — это набор состояний, которые сменяют друг друга. В любой момент времени игра находится в каком-то состоянии. Это состояние имеет вид и определяет возможные взаимодействия. Например: Splash, Loading, MainMenu, Play, Pause, CutScene, GameOver...

Часто игра управляется набором случайных сцен, менеджеров, цепочек callback и enum’ов. Такая архитектура размазывает ответственность и создаёт огромную паутину связей и зависимостей, которые не разрулить никакими DI или иными подходами, потому что они не убирают связь, а только прячут, откладывают, ослабляют. Более того, такое состояние ведомое, а должно быть ведущим.

Почему игровой flow начинает ломать проект

Пока игра маленькая, игровой flow часто выглядит безобидно:

  • одна-две сцены;
  • несколько окон меню;
  • переход в gameplay через одну кнопку;
  • немного coroutine-логики на загрузке.

Но по мере роста проекта начинает появляться знакомая боль:

  • новый экран ломает старую логику навигации;
  • игровая фаза меняется из случайного callback;
  • UI и загрузка сцены оказываются связаны через скрытое состояние менеджера;
  • чтобы протестировать один экран, нужно пройти пол-игры;
  • асинхронные переходы превращаются в хрупкие цепочки coroutine, await и событий;
  • владение ресурсами и системами становится неочевидным.

В этот момент проблема уже не в отдельных bugs.
Проблема в том, что у игры нет явного слоя управления игровым flow.

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

2. Game Flow

GameFlow — это стейт-машина с подсостояниями, которая переключает состояния игры.
Иерархия подсостояний: Root → GameStage → State...
Наверху Root, он состоит из крупных этапов игры (GameStage), таких как Boot, Menu, Play... и каждый из них живёт в своём контексте.
Внутри них — обычные состояния (State): Accept GDPR, Load Bundles, MainMenu, Settings, Shop, Arsenal, Playing, Pause, Killcam, Cutscene...

GameFlow переключает игру из одного состояния в другое, и остальные подсистемы могут или считать текущее состояние (что возвращает нас к лишним связям, хоть и развёрнутым), или быть частью состояния так, чтобы, если игра в другом состоянии, подсистема не работала или вообще не существовала. Тогда нет лишних связей и проверок — один источник правды! Текущее состояние игры!

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

Второй очень важный момент — это то, что именно состояние управляет игрой, точнее, состояние и его активные подсостояния.

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

Одна модель для Boot, Metagame и Coregame

Игровой flow удобно представить как иерархию крупных этапов и вложенных состояний. Boot готовит игру, Meta отвечает за метагейм и меню, Core — за основной игровой процесс. В разных проектах эти этапы могут называться Menu и Play.

Root
├── Boot · подготовка
│   ├── Splash
│   ├── EULA
│   ├── Consent
│   └── Загрузка профиля и ресурсов
├── Meta · метагейм
│   ├── MainMenu
│   ├── MapSelect
│   ├── Settings
│   ├── Shop
│   └── Leaderboard
└── Core · игровой процесс
    ├── Play
    ├── Cutscene
    ├── NPCDialog
    ├── Win
    ├── Lose
    ├── Pause
    └── Settings
Полная схема состояний среднестатистической игры: большие этапы, основные состояния и отдельные слои для overlays и popups.
Иерархия игры: этапы Boot, Meta и Core, вложенные состояния, сцены и слои всплывающих окон

Это пример декомпозиции. Небольшой игре может хватить Menu и Play. В большой игре понадобятся отдельные этапы входа, лобби, матча и результатов. Отдельное состояние полезно там, где меняются доступные действия, управляющая логика или самостоятельный пользовательский сценарий.

3. Game State

У каждого состояния есть своя логика и визуальное представление: например, SettingsUI, PlayState, PauseState или CutsceneState. Поэтому GameFlow может показаться системой для интерфейса, но он гораздо фундаментальнее. Я рекомендую строить интерфейс поверх него, но это не обязательно.

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

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

Состояние не просто описывает текущую ситуацию: оно управляет игрой в этой ситуации и определяет, когда готово передать управление следующему состоянию. У состояния есть входные данные, управляемая им работа и результат. Например, окно выбора карты получает список доступных карт и возвращает выбор игрока. Бой получает карту, режим и состав участников, а после завершения возвращает исход матча. Такой контракт делает состояние самостоятельным модулем: вызывающий сценарий задаёт параметры и получает результат, а само состояние решает, как показать данные, какие действия разрешить и когда его работа завершена.

Состояние Play считывает ввод и передаёт управление главному герою, а также отображает текущий статус игры (HUD). Так у игры появляется один согласованный источник правды о текущем режиме работы.

4. Иерархия и история — две разные связи

Иерархия определяет, кому принадлежит состояние и в каком контексте находится. История определяет, каким путём пользователь пришёл в него и куда вернётся. Например, Settings принадлежит этапу меню, но может быть открыто из MainMenu или из Shop. Путь назад различается, хотя владелец остаётся тем же.

Дочерние состояния помогают разложить локальный сценарий. Shop владеет вкладками, а Play — игровыми фазами. У каждой ветки есть собственная ответственность, которую не приходится переносить в один общий менеджер.

Важно различать состояние как модуль и конкретное его открытие. Один и тот же Leaderboard может открываться с разными фильтрами и в разных точках навигации. Входные данные и история относятся именно к этому открытию.

5. Логическое состояние и его показ

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

Состояние может оставаться живым, даже когда спрятано. Данные и работа, которые нужны на протяжении всего времени жизни состояния, сохраняются до его закрытия. Обработка ввода и взаимодействие с интерфейсом нужны только тогда, когда состояние показывается, поэтому прекращаются при его скрытии.

Показ, скрытие и закрытие состояния не всегда идут попарно. Например, окно показали, затем скрыли при переходе к следующему состоянию, а позднее закрыли вместе со всем этапом игры. Для закрытия его не нужно снова показывать и проигрывать анимацию скрытия. Освобождение ресурсов должно выполняться независимо от того, какие анимации были проиграны.

6. Крупные этапы и время жизни окружения

GameStage — крупный этап игры с общим временем жизни: подготовка, метагейм или матч. Внутри него живут состояния, сцены и данные, нужные этому этапу.

Крупные состояния — GameStage — часто отвечают за загрузку и выгрузку сцен:

  • Boot загружается из BootScene — первой сцены игры.
  • Meta загружает MetaScene, например сцену лобби.
  • Core загружает и выгружает игровые карты.

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

Владение должно быть явным. Общая конфигурация игры живёт дольше отдельного боя. Данные конкретного матча, наоборот, завершают жизнь вместе с ним. При переходе к следующему бою новый этап получает собственное окружение.

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

7. Переходы и загрузка сцен

Логика flow определяет, к какому состоянию нужно прийти. Визуальный переход определяет, как это произойдёт на экране: когда закрыть старое представление, показать загрузчик, подготовить новое окружение и передать управление.

Для тяжёлого перехода Meta → Core последовательность может быть такой:

  1. Завершить работу старого пользовательского сценария.
  2. Закрыть экран загрузочным представлением.
  3. Освободить больше не нужную сцену и её ресурсы.
  4. Подготовить окружение матча и загрузить карту.
  5. Показать Play и передать ему управление.

Так порядок действий становится частью архитектуры. Несколько запросов в одном кадре должны согласованно менять flow; визуальные переходы исполняются в контролируемом порядке, а не запускаются независимо из случайных callbacks.

Сцена становится ресурсом состояния. На одной сцене могут смениться Play, Pause и Cutscene. Один большой этап может использовать несколько сцен. Это позволяет менять навигацию без обязательного переделывания разбиения проекта на сцены.

8. Подсостояния, слои и независимые сценарии

Вкладки магазина удобно моделировать как подсостояния Shop. Сам Shop отвечает за переключение, а каждая вкладка самостоятельно показывает данные и обрабатывает свой сценарий. Это позволяет разрабатывать вкладки отдельно и использовать их в другом окружении.

Слои нужны, когда одновременно живут основной экран и всплывающее окно, или плей-режим и отдельный гейм-мод, или отдельный оверлей. У основного flow и popup есть собственная навигация, но их работа согласована общим владельцем. Такой подход помогает сохранить порядок вместо произвольных переключений видимости множества объектов.

Пауза в сетевой игре

Игрок открыл Pause, затем Settings. В это время Play может смениться на Killed. Пользователь должен закончить работу с настройками, а затем вернуться к актуальному состоянию матча. Для такого поведения состояния надо уметь лочить от внешних переключений.

Split-screen

У каждого игрока могут быть свои состояния, ввод, камера и HUD, живущие в его контексте. Тогда открытие Killcam для одного игрока не переключает экран другого. Данные общего матча остаются на общем уровне.

9. Разработка состояний в изоляции

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

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

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

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

10. Как складывается игровая сессия

Boot подготавливает игру: соглашения, профиль, конфиги и ресурсы. После готовности открывается Meta с главным меню. Игрок выбирает карту и режим, и начинается этап Core.

Core поднимает окружение матча и загружает карту. Play показывает HUD, принимает ввод и управляет игровыми системами. Пауза, катсцена и NPC-диалог меняют доступные действия через самостоятельные состояния.

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

Так flow отражает реальные сценарии игры. У каждого состояния понятны владелец, входные данные, результат и момент завершения. Это делает изменение навигации и добавление игровых фаз обычной работой с модулями.

11. Реализация во Flexy.GameFlow

Эта модель управления игрой развивалась более 10 лет на многих проектах в разных компаниях. Сейчас её гибкая и легко настраиваемая версия, готовая к использованию, реализована мной в пакете Flexy.GameFlow для Unity.

Все темы Base