Проектируем очередь игровых попапов

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

Зачем?

Предположим, вы добавили в игру окошко инвентаря персонажа, которое вызывается по нажатию на клавиатуре заданной клавиши ("I" по умолчанию) или по кнопке на экране и закрывается при нажатии на кнопку закрытия или стандартный хоткей (в моём случае escape), а также повторное нажатие "I".

Пока всё отлично работает, но позже добавляется вызов главного меню игры, которое должно открываться по escape, если сейчас нет никаких других окон. При этом с ним ничего не должно происходить, если в момент нажатия esc открыт инвентарь. Теперь уже не получится разбираться с изменением состояния там же, где оно произошло: в диалоге приходится реагировать на то, что произошло с игрой где-то в другом месте. Можно завести машину состояний, которых пока будет 3: "нет окон", "есть главное меню", и "есть окно инвентаря", и в каком-нибудь менеджере ввода реагировать по-разному на сигнал о том, что юзер захотел закрыть текущее окно.

Но, если позже, в инвентаре добавится возможность перетаскивания предметов с диалогом подтверждения количества, которое мы хотим переложить или выкинуть (т.е. игровые окна начнут отображаться поверх друг друга), этого уже будет недостаточно: теперь состояние системы зависит не только от того, какое окно поверх всего сейчас, но и от того, как к этому пришли. Такое состояние с историей изменений оптимально представляется в виде очереди.

Основные сущности

На самом высоком уровне система состоит из следующих узлов:

Проектируем очередь игровых попапов

Менеджер ввода

Получает сигналы ввода от движка + преобразованные события, которые должны восприниматься аналогично (например, нажатие на кнопку закрытия в UI). По итогу полученного отправляет команды очереди попапов. Если команда успешно применилась, считывает новое состояние очереди и отправляет его в шину событий.

Шина событий

В моём случае это просто класс, хранящий множество делегатов C#, на которые можно подписываться (в контроллерах) и вызывать их (в менеджере ввода). В каком-то более продвинутом случае это может быть UniRX или что-то аналогичное. Есть ещё события Unity, но я не очень понимаю, зачем они нужны кроме возможности работать из инспектора.

Очередь

Ключевой элемент всей системы. Содержит следующие данные:

  • стек видимых элементов (last in - first out, т.е. последнее показанное окно будет скрыто первым). Нужен для представлений окон, которые сейчас отображаются на экране
  • очередь элементов на показ (first in - first out, первым будет показан первый добавленный в очередь элемент). Нужна для окон, которые должны показаться, когда будут подходящие условия, но сейчас не могут
  • последняя операция, которая привела к текущему состоянию - см. ниже
  • (опционально) текущий режим работы игры. У нас есть 3 основных режима (они же сцены), в которых очередь ведёт себя по-разному, но в общем случае это не нужно
  • (опционально) флаги для каждого типа экрана - в нашем случае нельзя показать 2 окна одинакового типа в одной очереди: если уже есть диалог подтверждения, и поступила команда открыть ещё один, новый диалог отправится в очередь на показ и станет видимым, когда закроется текущий и при ещё нескольких условиях. Очередь не позволяет осуществлять произвольный доступ к элементам, поэтому пришлось завести отдельный словарь с ключом - типом окна, и булевым значением

Контроллер попапа

В отличие от всего выше перечисленного (кроме менеджера ввода, но и то только в моём случае), должен быть полноценным MonoBehaviour (или быть непосредственно с ним связан, если вы используете какой-то вариант MVP): подписываться на изменения в оконной системе от шины данных, в соответствии с ними показывать и скрывать интерфейс того диалога, за который отвечает, отправлять сигналы в шину событий, если нужно закрыться или открыть что-то ещё поверх.

Теперь подробнее о том, как работает очередь, начиная с того, из чего она состоит:

Элемент очереди

Объект, который отправляется в контроллер окна для отображения содержимого — оно может быть самым разным, но некоторые свойства должны быть в любом случае:

  • тип — enum, обозначающий уникальное окно в игре. В моём случае получилось 14 настоящих типов окон ("диалог-диалог" с репликами, бинарный диалог "да-нет", инвентарь и т.д.) и псевдо-тип NoPopup, обозначающий отсутствие чего-либо закрывабельного на экране
  • список типов элементов, которые не могут отображаться поверх текущего — нужен для проверки при попытке показа нового элемента. Можно пойти от обратного и сделать список разрешённых поверх текущего, тогда при пустом списке поверх данного окна ничего нельзя будет показать, но по мне это менее удобно. Если новый элемент провалил проверку, он отправляется в очередь на показ, которая проверяется при каждом новом скрытии видимого элемента
  • тип операции, с которой открывается этот элемент

Тип операции

Выше я уже писал, что, когда несколько элементов показывается поверх друг друга, важно не только то, что показывается сейчас, но и то, каким образом система пришла в такое состояние: если нам нужно показать диалог, который до этого был в очереди, необходимо сделать его UI видимым, а перед этим инициализировать все элементы с нужными значениями. Если же этот диалог снова оказался на вершине, но потому, что был скрыт предыдущий, не нужно ничего обновлять в его содержимом. Но это, опять же, зависит от специфики конкретного проекта. У нас получилось 4 типа операций, меняющих состояние очереди:

  • перекрыть предыдущее окно (оно остаётся видимым, но заслоняется)
  • скрыть предыдущее окно (оно скрывается, но покажется снова, когда будет закрыто текущее - если в процессе больше ничего не добавится в очередь)
  • смержить предыдущее окно - специфический способ появления для диалогов с репликами. Если предыдущее окно не является таким диалогом, всё работает как в случае перекрытия, иначе новое окно заменяет собой старое с перерисовкой содержимого - чтобы не было эффекта "моргания" со скрытием и показом одного и того же диалога
  • появиться после скрытия - отправляется, если система вернулась к окну, которое было ранее закрыто новым

Общение очереди с "внешним миром"

Взаимодействие с менеджером ввода (или юнит-тестами) сводится к 3-4 командам, при удачном завершении которых менеджер ввода оповещает шину событий о том, что состояние системы попапов изменилось

  • (необязательно) инициализация - переключить работу системы в один из режимов, если их несколько, и вернуть базовый элемент, который считается "фоном" для остальных: например, главное меню в начале игры нельзя закрыть, всё остальное будет отображаться уже поверх него
  • закрыть текущий элемент (или открыть главное меню, если ничего не отображается). Может завершиться удачно или нет: некоторые диалоги нельзя закрыть обычным способом (например, диалог с репликами, который нельзя пропустить, не должен закрываться по нажатию Esc). Для закрытия таких диалогов я пока не придумал ничего лучше параметра force, который закрывает любые диалоги и отправляется, если выбрана завершающая реплика. Как вариант, можно было сделать ещё одну команду, но это было бы слишком громоздко
  • добавить новый элемент. Аналогично: может завершиться неудачей, если новый элемент не может быть поверх текущего, или окно такого же типа уже есть в очереди. В этом случае пришедший попап добавляется в очередь и будет пытаться показаться при каждом закрытии
  • добавить элемент из очереди: вызывается после закрытия текущего элемента. Завершается неудачей, если в очереди ничего нет, иначе будет вызываться при каждом закрытии, пока очередь не опустеет
Здесь здорово то, что очередь сама по себе ничего не знает о движке, поэтому под неё достаточно просто пишутся юнит-тесты вроде таких
Здесь здорово то, что очередь сама по себе ничего не знает о движке, поэтому под неё достаточно просто пишутся юнит-тесты вроде таких

После того, как команда успешно завершилась, дело за малым — отправить текущее состояние тем, кто подписался на изменения. Состояние сводится к 2 переменным: текущий элемент на вершине очереди + тип последней операции, в результате которой он там оказался.

После этого каждый контроллер попапа (отвечающий за свой тип) проверяет несколько моментов и исходя из этого может изменить состояние UI:

  • активен ли попап сейчас? (совпадает ли наш тип с тем, который сейчас на вершине)
  • был ли активен до этого?
  • есть ли вообще активные окна?

Далее в зависимости от типа операции может ничего не произойти, или окно будет скрыто, показано, или обновлено. Эта логика пишется один раз в базовом классе, остальным нужно только переопределить 2 метода:

  • UpdateContent — в метод отправляется текущий логический элемент очереди, который нужно отрисовать — конкретный подкласс попапа должен знать, как
  • ChangeUiVisibility — в метод отправляется булев флаг, в нём нужно показать/скрыть элементы интерфейса - у основного класса это подложка диалога, кнопка его закрытия (и то в случае диалога с репликами её нет) и фоновое затенение, которое активно, если сейчас что-то отображается

Код

Очередь попапов:

Интерфейс элемента очереди:

Пример реализации (для главного меню):

Реализация для элемента, обозначающего операцию закрытия текущего окна (сам он в очередь не добавляется, но меняет последнюю операцию):

Типы элементов:

Менеджер ввода + его события (те места, которые связаны с очередью)

Базовый класс для контроллера окна:

Пример наследника (попап окончания игры):

Пример сетапа контроллера окна:

в поле dialogue: game object, который будет появляться при показе диалога, сlose button: кнопка закрытия диалога, highlight - затенение, которое активно, пока хотя бы один диалог показывается над исходным состоянием
в поле dialogue: game object, который будет появляться при показе диалога, сlose button: кнопка закрытия диалога, highlight - затенение, которое активно, пока хотя бы один диалог показывается над исходным состоянием

Надеюсь, получилось не очень сумбурно и кому-то будет полезно при проектировании своей системы диалогов.

Результат

Примерно так всё выглядит:

Часть диалогов ещё не отрисована в итоговом виде, поэтому страшна, но в течение 3-4 месяцев, надеюсь, это исправим

Да, мы (пока называемся DieselDuck) в свободное от основной работы время не спеша делаем ретрофутуристичную Single-player ролевую игру про дирижабли Through the clouds (Сквозь облака). В плане контента пока мало что можно показать — сейчас большую часть времени занимают осевые элементы, которые потом позволят более просто добавлять всё остальное, но уже можно подписаться на наши медиа, чтобы следить за развитием, а ещё поиграть в демо на itch.io:

2424
1 комментарий

Всякие окошечки и подсказочки самая нудная часть разработки, но необходимая. У меня еще много окон с динамическими данными, т.е. в одном окне может быть разная инфа или взамодействие. Даже не знаю нужно ли так делать, опыт покажет. Я для себя решил сделать так. У каждого окна или ему подобного есть дефолтное состояние, как правило не показывается и не просчитывается инфа. И если вызывается другое окно, то ВСЕ переходит в это дефолтное состояние. Если что то не так, то меняю это дефолтное состояние. Но я свою оконную систему пишу. На движках может этого не быть.