Кроссплатформенный мультиплеер на Godot без боли

от автора

Что хотим сделать?

Синхронизацию действий игроков в игре с клиент-серверной архитектурой. Должна быть возможность играть из браузера.

Для примера реализуем простую чат-комнату:

  1. При соединении:

    1. Клиент получает уникальный ID;

    2. Клиент получает информацию о всех остальных игроках (ID + имя);

    3. Все остальные игроки получают информацию о новом игроке (ID + имя по умолчанию);

    4. В консоли появляется сообщение о входе.

  2. При потере соединения:

    1. Все остальные игроки получают информацию о выходе игрока с сервера (ID);

    2. В консоли появляется сообщение о выходе.

  3. При изменении имени:

    1. Если имя уже занято — игрок получает ошибку;

    2. Все игроки уведомляются об изменении имени;

    3. В консоли появляется сообщение.

  4. При отправке сообщения в чат:

    1. Все игроки видят сообщение в логе/консоли.

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

Что получилось?

Готовый проект можно изучить здесь: https://github.com/ktori/godobuf-over-websocket-demo

Скриншоты можно посмотреть в конце статьи.

Что будем использовать?

  • Godot — free and open source кроссплатформенный игровой движок;

  • Protobuf — механизм для эффективной сериализации/десериализации данных;

  • Godobuf — плагин для Godot, позволяющий генерировать .gd (GDScript) файлы из .proto;

  • Ktor — фреймворк для создания асинхронных сервисов Kotlin (в этой статье я буду использовать Kotlin — но бэкэнд может быть написан на любом другом языке, главное — иметь в фреймворке возможность принимать вебсокет-соединения и желательно — генератор кода из Protobuf, эти генераторы существуют для множества языков).

Плюсы этого подхода

  • Все сообщения, которыми обмениваются клиент и сервер, описываются в одном месте:

    • Из этих файлов можно сразу сгенерировать код и для сервера и для клиента;

    • В них же можно вести документацию, оставляя комментарии;

    • Описание протокола можно легко хранить в любой VCS, т.к. по сути это просто текстовые файлы;

    • Можно точно знать что обе стороны будут сериализовывать и десериализовывать сообщения одинаково — генерация кода обеспечит отсутствие забытых полей и каких-либо других ошибок свойственных при ручном чтении/записи.

  • Protobuf — бинарный формат, и в отличие от, например, JSON — будет использоваться меньший объем трафика для передачи одного и того же объема данных;

  • Protobuf позволяет добавлять новые поля, не ломая совместимость со старыми клиентами.

Минусы этого подхода

Совсем явных минусов я назвать не могу — но:

  • Сериализация/десериализация в protobuf будет проходить медленнее, чем, например, прямая запись в буфер в собственном формате;

  • Код, который генерируется из protobuf часто получается довольно громоздким и, соответственно, имеет определенную стоимость в рантайме.

Описание протокола

Готовый протофайл можно посмотреть здесь: game.proto

Создадим пустой .proto-файл, например — game.proto. В этом файле нужно описывать все сообщения, которыми будут обмениваться сервер и клиент (если сообщений будет много — можно выносить их в отдельные файлы и импортировать из основного).

В этот файл следует сразу прописать опции для парсера и кодогенератора:

syntax = "proto3";  // Название пакета option java_package = "me.ktori.game.proto"; // Название класса в котором будут находиться подклассы сообщений option java_outer_classname = "GameProto";

А теперь определимся, какие сообщения нам вообще нужны:

Сообщения клиент-сервер

Это сообщения, которые клиент отправляет серверу — часто они будут по сути RPC вызовами с ответом в сообщении Cl**Result от сервера. Здесь был бы очень кстати gRPC — возможно в будущем с помощью godobuf можно будет делать и gRPC-сервисы. Но пока:

// // Сообщения клиент-сервер //  // Запрос на изменение имени message ClSetName {   string name = 1; }  // Отправка сообщения в чат message ClSendChatMessage {   string text = 1; }  // Объединение всех сообщений, отсылаемых клиентом message ClMessage {   // Только одно из этих полей может быть заполнено, таким образом сервер   // может быстро определить, что именно хочет сделать клиент   oneof data {     ClSetName set_name = 1;     ClSendChatMessage send_chat_message = 2;   } }

Сообщения сервер-клиент

// // Сообщения сервер-клиент //  // Результат выполнения команды ClSetName message ClSetNameResult {   // Удалось ли изменить имя - имя нельзя изменить на уже занятое   bool success = 1; }  // Отсылается сервером - объединение всех возможных результатов выполнения команды от клиента message ClMessageResult {   oneof result {     ClSetNameResult set_name = 1;   } }  // Отсылается клиенту один раз при соединении // Получатель этого сообщения сохраняет у себя полученный ID и выданное сервером имя message SvConnected {   int32 id = 1;   string name = 2; }  // Уведомление о подключении нового клиента // Получатель должен сохранить имя клиента по ID message SvClientConnected {   int32 id = 1;   string name = 2; }  // Уведомление об отключении клиента // Получатель может удалить у себя информацию о клиенте по ID message SvClientDisconnected {   int32 id = 1; }  // Уведомление об изменении имени // Получатель должен изменить имя клиента по ID на новое message SvNameChanged {   int32 id = 1;   string name = 2; }  // Сообщение в чате message SvChatMessage {   int32 from = 1;   string text = 2; }  // Объединение всех сообщений которые сервер посылает клиенту message SvMessage {   // Только одно из этих полей будет заполнено в одном SvMessage   oneof data {     ClMessageResult result = 1;     SvConnected connected = 2;     SvClientConnected client_connected = 3;     SvClientDisconnected client_disconnected = 4;     SvNameChanged name_changed = 5;     SvChatMessage chat_message = 6;   } }

Таким образом получаем следующую структуру:

  • Все возможные сообщения от клиента обернуты в ClMessage;

  • Все возможные сообщения от сервера обернуты в SvMessage;

    • Ответы на вызовы клиента обернуты в поле result — сообщение ClMessageResult.

Лично для себя я определилась с такой naming convention:

  • ClFooBar для сообщений, которые шлёт клиент серверу;

  • SvFooBar для сообщений, которые шлёт сервер клиенту, за исключением:

  • ClFooBarResult для передачи результата обработки ClFooBar.

Создание клиентской части на Godot

Для начала нужно создать проект и основную сцену (обычную пустую 2D сцену).

Добавление плагина Godobuf

Плагин можно скачать здесь: https://github.com/oniksan/godobuf, инструкция по установке есть в README репозитория — нужно распаковать себе в проект папку addons.

Проект после установки аддона godobuf
Проект после установки аддона godobuf

Открытие соединения

Для соединения с сервером используется класс WebSocketClient (документация по WebSocketClient). Работать с ним просто: устанавливаем обработчики событий, а затем указываем URL сервера для соединения.

Создадим скрипт, который будет открывать соединение на корневой ноде сцены — там же будут заготовки для функций обработки событий от вебсокета:

extends Node2D  var ws: WebSocketClient  # Вызывается при загрузке сцены func _ready():     # Создаем WebSocketClient и подключаем обработчики событий     ws = WebSocketClient.new()     ws.connect("connection_established", self, "_on_ws_connection_established")     ws.connect("data_received", self, "_on_ws_data_received")     # Подключаемся к локалхосту по порту 8080     ws.connect_to_url("ws://127.0.0.1:8080")  # Будет вызываться при установке соединения func _on_ws_connection_established(_protocol):     pass  # Будет вызываться при получении сообщений из вебсокета func _on_ws_data_received():     pass

Генерация биндингов protobuf:GDScript

Здесь всё очень просто! Во вкладке Godobuf указываем путь до нашего proto-файла и путь куда будет сохранен получившийся скрипт:

Окно Godobuf
Окно Godobuf

Если в прото-файле нет ошибок, то мы увидим сообщение об успешной компиляции и в папке проекта появится нужный скрипт.

Отправка сообщений

Настройка сцены

 Сцена
Сцена

В своей сцене я сделала отдельный контейнер для сообщений и два поля — для ввода текста и имени. Сигналы pressed от кнопок Send и Rename я подключила в скрипт на корневой ноде. Также для вывода сообщений на сцену я сделала функцию show_message, она просто добавляет новый объект Label с текстом сообщения в VBoxContainer, который располагает объекты вертикально.

Отправка запросов на сервер

После создания этих полей ввода и кнопок нужно сделать так чтобы они что-то делали.

Сперва загрузим получившиеся биндинги в наш скрипт:

const GameProto = preload("res://game_proto.gd") 

Теперь можно добавить код создания ClMessage при нажатии на кнопки Send/Rename:

# Изменяем имя на введенное в $Name func _on_SetName_pressed():     var msg = GameProto.ClMessage.new()     var sn = msg.new_set_name()     sn.set_name(name_input.text)     send_msg(msg)  # Отправляем сообщение из $Message и очищаем поле func _on_SendMessage_pressed():     var msg = GameProto.ClMessage.new()     var scm = msg.new_send_chat_message()     scm.set_text(message_input.text)     message_input.clear()     send_msg(msg)

Самое интересное — сама отправка сообщения по вебсокету происходит в функции send_msg. Вот она:

# Отправляет ClMessage на сервер func send_msg(msg: GameProto.ClMessage):     # Конвертируем ClMessage в PoolByteArray и отправляем его по соединению ws     ws.get_peer(1).put_packet(msg.to_bytes())

Функция to_bytes (как и весь класс ClMessage) сгенерированы плагином godobuf — и никаких операций с буферами руками нам делать не надо!

Обработка сообщений

Теперь наш клиент может отправлять сообщения — но он ещё не способен их принимать. Сейчас мы это исправим, добавив обработку входящих сообщений — этот блок кода будет объемнее, но по большей части код там повторяется.

Код получения и обработки сообщений
# Вызывается часто по интервалу func _process(_delta):     # Производит чтение из вебсокета, читает входящие сообщения     ws.poll()  # Будет вызываться при установке соединения func _on_ws_connection_established(_protocol):     show_message("Connection established!")  # Будет вызываться при получении сообщений из вебсокета func _on_ws_data_received():     # Обработка каждого пакета в очереди     for i in range(ws.get_peer(1).get_available_packet_count()):         # Сырые данные из пакета         var bytes = ws.get_peer(1).get_packet()         var sv_msg = GameProto.SvMessage.new()         # Превращение массива байтов в структурированное сообщение         sv_msg.from_bytes(bytes)         # Обрабатываем уже сконвертированное сообщение         _on_proto_msg_received(sv_msg)  # Будет вызываться после чтения и конвертации сообщения из вебсокета func _on_proto_msg_received(msg: GameProto.SvMessage):     # т.к. все эти поля находятся в блоке oneof - заполнено может быть только     # одно из них     if msg.has_connected():         pass     elif msg.has_client_connected():         pass     elif msg.has_client_disconnected():         pass     elif msg.has_chat_message():         pass     elif msg.has_name_changed():         pass     elif msg.has_result():         pass     else:         push_warning("Received unknown message: %s" % msg.to_string()) 

Важно периодически вызывать poll на WebSocketClient, иначе сигналы о входящих сообщениях никогда не придут. В данном случае это происходит в _process

После этого остается только заполнить логику обработки конкретных сообщений — но сначала добавим хранилище известных клиенту имён и переменную для ID текущего клиента:

# Хранит ID этого клиента var own_id: int # Хранит пары ID <> Имя var names = Dictionary()

И обработку одного из возможных сообщений с сервера:

# Внутри _on_proto_msg_received   if msg.has_connected(): 		var c = msg.get_connected() 		own_id = c.get_id() 		name_input.text = c.get_name() 		show_message("Welcome! Your ID is %d and your assigned name is '%s'." % [c.get_id(), c.get_name()])

Остальные блоки в этом if/elif примерно одинаковы. Получившийся код для каждого отдельного сообщения можно посмотреть на GitHub: Main.gd

Серверная часть

Серверная часть очень подробно разбираться не будет. Её можно написать на любом языке — и в данном случае это будет Kotlin с фреймворком Ktor. Напоминаю, что весь код этого проекта доступен на GitHub — сервер там достаточно простой. Но в двух словах выделю основные моменты моей

сервера:

Структура проекта

Основной gradle-проект состоит из двух модулей:

  • server — сам сервер;

  • proto — прото-файлы и сгенерированные из них биндинги:

    • Стоит обратить внимание на плагин com.google.protobuf, зависимость com.google.protobuf:protobuf-java и их конфигурацию;

    • В процессе сборки этого модуля генерируются классы, позволяющие сериализовывать/десериализовывать сообщения описанные в прото-файле.

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

Результаты

Получившийся Godot-проект может работать как из браузера, так и с нативных сборок под Linux/Windows/Android и т.д. — всё взаимодействие клиента с сервером описывается в одном месте и в протокол легко вносить изменения.

Скриншоты

Нативный клиент
Нативный клиент
WebSocket-клиент
WebSocket-клиент

Заключение

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

  • Обработку ошибок (например, передавать отдельное сообщение error в ClMessageResult);

  • Обработку потери/восстановления соединения;

  • Многое другое.

Я надеюсь эта статья оказалась полезной и помогла разобраться в Godot, вебсокетах и protobuf.

ссылка на оригинал статьи https://habr.com/ru/post/540034/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *