Как я создавал онлайн игру «нарды» (часть четвертая). Сервер

Всем привет!

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

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

Напомню, что после аутентификации пользователя все общение между клиентом и сервером происходит с использованием WebSocket транспорта, и все запросы отправляются на сервер в виде RPC пакетов, которые обрабатываются в брокере сообщений. Описание его работы я делал во второй части. Все WebSocket соединения авторизуются в процессе подключения и содержат необходимую информацию о пользователе, который его инициировал. Кстати на прошедшей неделе я переписал часть сервера для использования в качестве среды исполнения кода и менеджера пакетов — BUN. Данное решение было принято в том числе из-за того, что он использует для реализации HTTP и WebSocket транспорта библиотеку uWebSockets.js. Описание всех преимуществ перехода на BUN тянут на отдельную статью, по этому я пока могу порекомендовать всем заинтересовавшимся почитать об этом замечательном продукте на его официальном сайте.

На старте игры, необходимо создать саму игру, для этого существует специальный метод game.create, которые принимает в качестве параметров название и режим игры, в настоящий момент игра одна, а вот режимов игры уже несколько — «игра с другом», «игра с ИИ» и «игра со случайным оппонентом» — PvP. При создании игры я генерирую специальный ключ, который используется в дальнейшем для подключения к игре и передачи всех остальных данных в игру. Я использую nanoid со своим набором символов, чтобы исключить символы минуса, подчеркивания и буквы в нижнем регистре, а также устранить путаницу с нулем и буквой «О». Этой библиотекой я пользуюсь достаточно давно, она быстрая, надежная и имеет калькулятор коллизий, который позволяет выбрать подходящую длину генерируемого ключа учитывая частоту генерации в единицу времени или необходимое количество генераций до наступления первой коллизии.

fc483ca7b35c76f5943860b7587816fa.jpg

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

В процессе создания игры также предварительно создается случайный набор бросков кубиков (156 пар). Таким образом реализован механизм честности игры. Невозможно изменить набор бросков после генерации, а до окончания игры можно скачать архив с бросками, который закрыт паролем. Узнать пароль можно только после окончания игры. В интерфейсе клиента данный функционал еще не реализован, но методы на стороне сервера уже реализованы и протестированы. Помимо этого для игры выставляется срок жизни, после которого игра считается просроченной и доиграть ее уже будет невозможно.

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

Ниже описание этого пакета (BackgammonData).

type BackgammonData = {
  token: string;
  expired: number;
  players: TGamePlayer[];
  status: string;
  data: BackgammonGameState;
}

type BackgammonGameState = {
  firstDice: number[];
  steps: BackgammonStep[];
  board: BackgammonBoard;
}

type BackgammonStep = {
  subSteps: BackgammonSubStep[];
  player: number;
  dice: number[];
  done: boolean;
}

type BackgammonSubStep = {
  from: number;
  to: number;
}

type BackgammonBoard = {
  white: number[][];
  black: number[][];
}

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

После создания игры, как я писал выше, происходит подключение игрока к игре, для этого в модуле «игра» существует метод game.join, который принимает в качестве единственного параметра ключ (token). Метод ищет игру с переданным ключом в базе данных и в случае нахождения проверяет ее статус, он должен соответствовать статусу «в ожидании» либо «игра». В случае если статус «в ожидании», то идентификатор пользователя, отправившего этот запрос, добавляется в поле игроки (players), а игре выставляется статус «игра», если же значение статуса соответствует «игра», то проверяется наличие идентификатора пользователя в поле игроков. В случае прохождения этих проверок, метод возвращает обновленный пакет описания игры и подписывает WebSocket соединение отправителя на канал игры, а затем отправляет в канал игры сообщение о том, что к игре присоединился новый игрок.

После того, как клиентское приложение получило сообщение о том, что игроки готовы к игре, отображается первый бросок кубиков, который передается в шаге (BackgammonStep) и приложение включает интерфейс для взаимодействия с фишками на доске. После перемещения фишки, клиентское приложение передает информацию об этом на сервер. Так как в рамках одного шага в игре может быть несколько перемещений, то все они хранятся в виде массива BackgammonSubStep[], элементы которого содержат информацию откуда и куда переместили фишки.

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

Перед отправкой проверяется факт окончания хода и если ход закончен, то в массив ходов steps добавляется новый элемент BackgammonStep в котором указывается идентификатор игрока который ходит этот ход, а также берется следующее значение броска костей из списка бросков, который генерировался в момент создания игры. Тут важно отметить, что список бросков никогда не передается на сторону клиента и со стороны игрока невозможно узнать какие выпадут кости в следующем ходу. Помимо этого проверяется также факт окончания игры и если игра закончена, то победителю начисляется определенное количество очков и информация об этом передается в канал игры, а затем происходит отписка (unsubscribe) игроков от этого канала.

Если игра происходит в режиме «игра с ИИ», то после окончания хода пользователя, когда в состояние игры добавляется новый ход, информация об игре передается ИИ, который анализирует состояние доски и выбирает лучший ход из всех доступных, с учетом выпавших кубиков, после этого информация о ходе передается от ИИ назад в метод game.move и все происходит по описанному выше сценарию. ИИ в системе является равноценным пользователем, с той лишь разницей, что у него есть возможность играть одновременно в неограниченном количестве игр, в то время как у остальных активной может быть только одна игра.

Если по какой-то причине пользователь решил прервать игру, то приложение использует метод game.abort, куда передается ключ игры. Этот метод выставляет статус «прервана», а также сохраняет информацию какой именно игрок прервал игру. Конечно же в методе реализована проверка, что пользователь, отправивший запрос, находится в списке игроков и игра в статусе «в ожидании» или «игра». После публикации информации в канале игры о том, что она прервана, сервер отписывает игроков от этого канала.

Помимо описанных выше методов, модуль «игра» содержит еще несколько доступных для клиентов методов:

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

  • game.getFile — для загрузки архива со списком бросков кубиков

  • game.getLeaderboard — для получения списков лучших игроков, за все время, за текущие сутки и за текущую неделю

  • game.getCallCredentials — для получения ключа доступа к организации звонка оппоненту во время игры

  • game.getPublicGames — для получения списка текущих публичных игр (режим «игра со случайным оппонентом»)

  • game.getLogic — для загрузки логики игры на стороне клиента (тот самый общий класс для проверки возможных ходов и не только)

  • game.view — для подключения к игре в режиме наблюдателя

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

На этом сегодня я заканчиваю и предлагаю, по традиции, устроить голосование о чем рассказать в следующий раз.

Посмотреть в живую и поиграть в мои нарды можно на сайте или в telegram.

Всем хорошего дня!

© Habrahabr.ru