Всем привет!

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

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

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

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

Я выбрал длину в 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.

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

Комментарии (0)