Разработка
Реализация сетевой части в пошаговой игре
3 года назад я начал разработку Swords & Ravens, открытой многопользовательской онлайн-адаптации любимой мною стратегической настольной игры A Game of Thrones: The Board Game (Second Edition), разработанной Кристианом Т. Петерсеном и опубликованной Fantasy Flight Games. По состоянию на февраль 2022 года на платформе ежедневно собирается около 500 игроков, и с момента ее выпуска было сыграно более 2000 игр. Хотя я прекратил активно разрабатывать S&R, благодаря работе open source сообщества к ней продолжают добавлять новые функции.
Я многому научился во время разработки S&R, и я хочу поделиться некоторыми знаниями, которые я получил, с людьми, которые могут быть заинтересованы в реализации подобного проекта. Можно многое рассказать о том, как работает игра, но в этой статье я сосредоточусь на том, как я разрабатывал ее сетевую часть. Сначала я опишу проблему более формально. Я продолжу, объясняя, как это решается в S&R, а также опишу другие возможные решения, которые я обнаружил. Я подробно опишу преимущества и недостатки каждого метода и сделаю вывод, какой метод я считаю лучшим (спойлер: он последний 👀).
Постановка задачи
В одиночной игре все живет внутри одного компьютера. Действия игрока непосредственно применяются к игровому состоянию, а модификации этого игрового состояния отражаются на экране. В многопользовательской онлайн-среде все обстоит иначе. Каждый игрок играет на своем собственном компьютере, каждый из которых имеет свою текущую информацию об игре и свой собственный UI для отображения текущего состояния.
Общую архитектуру онлайн-игры можно обобщить следующей схемой:
Пользовательский интерфейс показывает игроку текущее состояние игры на основе локальной копии состояния игры. Клиенты отвечают за связь с сервером, как для отправки действий игрока, так и для получения новой информации о состоянии игры.
Проблема, которую мы хотим решить, заключается в том, как синхронизировать различные локальные состояния игры клиентов с игровым состоянием сервера. Более конкретно: когда сервер применяет действие игрока к своему игровому состоянию, как он должен сообщать об изменениях игрового состояния клиентам.
Способ распространения обновлений
Наиболее очевидным решением является применение к локальной игре любого действия, полученного сервером, и передача различных обновлений состояния игры клиентам. Следующая диаграмма показывает это в действии.
Это метод, используемый Swords & Ravens. Это просто, интуитивно понятно, и легко узнать, какие данные вы отправляете или нет разным клиентам. Это также упрощает обработку секретных данных (то есть данных, которые должны быть известны только подмножеству игроков). Если игрок берет карту и кладет ее в свою (секретную) руку, то вы можете передать, какая карта была взята, только этому игроку, чтобы ни один другой игрок не знал, какая это карта.
Первый недостаток этого метода заключается в том, что вы должны запрограммировать все возможные обновления состояния вашей игры. Хотя, безусловно, есть способы автоматизировать это, используя, например, декораторы для управления доступом к переменным вашего игрового состояния, это может сделать ваш код менее читаемым.
Второй недостаток заключается в том, что, поскольку вы, возможно, отправляете несколько обновлений для одного действия, локальное игровое состояние клиента может временно находиться в недопустимом состоянии до того, как будут получены все обновления. На диаграмме, показанной выше, между обновлением «Убрать пехотинца в Винтерфелле» и «Добавить пехотинца в Королевской Гавани» отсутствует изменение количества пехотинцев, отображаемое в пользовательском интерфейсе. Хотя эту конкретную проблему можно решить, отправив комбинированное обновление (например, переместить пехотинца из Винтерфелла в Королевскую Гавань), не все обновления можно легко объединить.
Лучшим способом решения этой проблемы было бы объединить все обновления, сделанные из-за действия, и отправить их сразу. По сути, это и есть следующий метод.
Способ распространения дельта-обновлений
Метод распространения дельта-обновления работает путем вычисления дельты между новым состоянием игры и состоянием игры до применения действия. Затем эта дельта отправляется клиентам, чтобы они могли применить ее к своему локальному состоянию игры. Так работает игровой движок boardgame.io.
Это устраняет 2 недостатка, описанных в предыдущем методе. Нам больше не нужно кодировать все возможные обновления, поскольку как только вы что-то измените, это будет вычислено в дельте после обработки действия. Вы больше не получаете временные недопустимые состояния, так как обновления будут применяться сразу, атомарно.
Однако мы потеряли одну вещь — простоту управления секретным состоянием. Если вы хотите предотвратить отправку какой-либо секретной информации конкретному клиенту, сервер должен отфильтровать дельту от любой потенциально частной информации перед ее отправкой клиентам.
Детерминированный метод распространения обновлений
Этот метод вдохновлен детерминированным методом строгих шагов, используемым в онлайн-играх в реальном времени.
Он основан на предположении, что обработка действий игрока является детерминированной. Это означает, что для данного состояния игры применение действия всегда будет давать нам одно и то же результирующее состояние игры. Мы можем использовать это свойство, чтобы избежать распространения всего обновленного состояния игры среди клиентов. Сервер может применить действие, полученное от клиента, а затем распространить это действие на клиентов, которые затем могут применить его для получения собственного нового игрового состояния. Поскольку применение действия является детерминированным, клиенты придут в то же состояние игры, что и сервер.
Это решение имеет множество преимуществ по сравнению с предыдущими.
Во-первых, нам не нужно программировать дополнительную сетевую логику в нашем коде. Единственное, что необходимо реализовать, — это распространение действия, совершаемого игроками.
Во-вторых, потребление пропускной способности не привязано к размеру изменений состояния игры. Если действие одного игрока изменяет 1000 объектов в нашем игровом состоянии, серверу все равно нужно будет передать только действие, а не все 1000 изменений. На самом деле это причина, по которой детерминированный шаг используется в стратегических играх в реальном времени, таких как Age of Empires. Хотя для пошаговых игр (тем более для настольных игр) довольно необычно иметь много движущихся объектов при выполнении действия, это открывает новые возможности даже для них.
В-третьих, поскольку реальный код игрового процесса запускается на клиенте, мы можем выполнять анимацию различных обновлений состояния игры. Например, если действие игрока уменьшит сумму его денег на 10, а затем увеличит ее на 40, мы могли бы воспроизвести 2 разные анимации на стороне клиента, в то время как с предыдущим решением мы бы получили от сервера только тот факт, что сумма денег была увеличена на 30, что не позволило бы клиенту воспроизвести анимацию.
В-четвертых, когда игрок решает выполнить действие, клиент может напрямую применить действие к собственному игровому состоянию после его отправки на сервер, не дожидаясь подтверждения от сервера. Этот процесс, называемый «оптимистическим обновлением», позволяет нам обеспечить игрокам бесперебойную работу.
В целом, это решение довольно элегантно. Нам просто нужно реализовать распространение действий игрока, и как только это будет сделано, та-да, мы можем сосредоточиться на реализации игрового процесса и вообще не нужно трогать сетевой код!
Однако есть один большой минус. Чтобы гарантировать, что клиенты и сервер придут в одно и то же игровое состояние после обработки действия, мы должны гарантировать, что они оба изначально имеют точно такое же игровое состояние. Поначалу это может заставить вас подумать, что секретное состояние невозможно. Действительно, как мы можем иметь состояние, которое сохраняется только на стороне сервера, если наше сетевое решение основано на том, что состояние игры одинаково для всех участников?
Обработка секретного состояния
Мы можем довольно элегантно решить эту проблему, позволив клиентам немного отличаться от сервера. Если для действия требуется состояние, которое ранее было скрыто для одного или нескольких клиентов, мы можем заставить сервер согласовать разницу, отправив эту конкретную часть игрового состояния клиентам.
Давайте проиллюстрируем это на примере из Swords & Ravens. Когда игрок перемещает свою армию на территорию другого игрока, он запускает бой. В процессе боя в S&R оба игрока одновременно выбирают из своей руки генерала своего дома, который возглавит их армии. Эта механика приводит к интересной интриге, когда оба игрока пытаются угадать, какого генерала возьмет их противник, чтобы они могли выбрать подходящего.
Очевидно, важно сохранить в тайне выбор одного из противоборствующих игроков, если другой игрок еще не сделал свой выбор.
Следующая диаграмма объясняет, как мы смогли решить эту проблему.
Когда Клиент А отправляет свое действие на сервер (Выбор Тайвина Ланнистера), мы распространяем это действие на обоих игроков, но не раньше, чем отфильтровываем выбранного лидера из сообщения, отправленного Клиенту Б. В этот момент игровое состояние Клиента Б отличается от состояния Сервера, так как неизвестно, какой лидер был выбран у А. Когда Клиент Б отправляет свое действие (выбор Маргери Тирелл), мы применяем ту же логику и отфильтровываем выбранного лидера из сообщения, отправленного Клиенту A. Поскольку оба игрока выбрали своих лидеров, мы можем согласовать различия в состоянии игры, отправив выбор обоих игроков. После этого небольшого маневра все клиенты имеют одинаковое игровое состояние и могут детерминистически решить оставшуюся часть боя.
Обратите внимание: хотя мы могли бы отказаться от отправки чего-либо Клиенту Б после того, как A выбрал своего лидера, отправка этой информации позволяет отобразить в пользовательском интерфейсе Б, что A уже выбрал своего лидера.
Заключение
Если бы мне пришлось разрабатывать Swords & Ravens с нуля, я бы использовал детерминированный метод. Необходимость реализовать работу с сетью только один раз и сделать это довольно элегантно и привлекательно. Поскольку AGoT:TBG — довольно сложная игра с множеством разных фаз, необходимость объединения каждого взаимодействия в сети приводила к созданию большого количества шаблонного кода, который составляет большую его часть. Вдобавок ко всему, мне никогда не удавалось легко добавлять анимацию (движение фигур, перемещение карт из руки на доску и т. д.) в пользовательский интерфейс, что не очень помогает с AGoT:TBG, где одно действие может иметь множество обновлений состояния.
Еще одна приятная особенность использования детерминированного метода заключается в том, что вы можете легко создать библиотеку, которая обрабатывает всю сетевую часть пошаговой игры, позволяя разработчику сосредоточиться на разработке механики самой игры. Я начал работать над такой библиотекой, Ravens. К сожалению, в силу внешних обстоятельств я не продолжил ее разработку.
Если вы на самом деле находитесь в процессе реализации пошаговой многопользовательской игры, я надеюсь, что эта статья смогла помочь вам в выборе архитектуры. Если нет, то надеюсь, что содержание и написание были достаточно интересными!