[Разработка игр, Godot] Кроссплатформенный мультиплеер на Godot без боли
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Что хотим сделать?Синхронизацию действий игроков в игре с клиент-серверной архитектурой. Должна быть возможность играть из браузера.Для примера реализуем простую чат-комнату:
- При соединении:
- Клиент получает уникальный ID;
- Клиент получает информацию о всех остальных игроках (ID + имя);
- Все остальные игроки получают информацию о новом игроке (ID + имя по умолчанию);
- В консоли появляется сообщение о входе.
- При потере соединения:
- Все остальные игроки получают информацию о выходе игрока с сервера (ID);
- В консоли появляется сообщение о выходе.
- При изменении имени:
- Если имя уже занято - игрок получает ошибку;
- Все игроки уведомляются об изменении имени;
- В консоли появляется сообщение.
- При отправке сообщения в чат:
- Все игроки видят сообщение в логе/консоли.
Примечание: ничего не мешает реализовать более сложный нетворкинг (например, передвижения игроков, какие-то другие действия) - но это выходит за рамки этой статьи и само по себе является достаточно сложной темой. Чат - это самый простой пример для демонстрации того, что такой подход для передачи данных, в принципе, работает - и цель моей статьи как раз в этом.Что получилось?Готовый проект можно изучить здесь: 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 Открытие соединенияДля соединения с сервером используется класс 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Если в прото-файле нет ошибок, то мы увидим сообщение об успешной компиляции и в папке проекта появится нужный скрипт. Отправка сообщенийНастройка сцены
СценаВ своей сцене я сделала отдельный контейнер для сообщений и два поля - для ввода текста и имени. Сигналы 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-клиентЗаключениеВ этой статье рассматриваются только самые основы этого метода. Помимо того что написано здесь, будет важно реализовать:
- Обработку ошибок (например, передавать отдельное сообщение error в ClMessageResult);
- Обработку потери/восстановления соединения;
- Многое другое.
Я надеюсь эта статья оказалась полезной и помогла разобраться в Godot, вебсокетах и protobuf.
===========
Источник:
habr.com
===========
Похожие новости:
- [Разработка игр, Читальный зал, Дизайн игр, Игры и игровые приставки] История Agent — отменённого шпионского боевика от Rockstar
- [Разработка игр, Игры и игровые приставки] Главный эксклюзив Stadia выпустят на ПК
- [Разработка игр, Конференции, Игры и игровые приставки] Бесплатная онлайн-конференция по гипер-казуальным играм
- [Разработка игр, Unreal Engine] Unreal Engine 4. Новая сетевая модель: PushModel
- [Работа с видео, Разработка игр, Управление продуктом, Продвижение игр] На чём лучше не экономить при создании ролика об игре
- [Разработка игр, Игры и игровые приставки] CD Projekt рассказала, как обходить ломающий прохождение баг в обновлении 1.1 для Cyberpunk 2077
- [Программирование, C++, Работа с 3D-графикой, Разработка игр, CGI (графика)] Vulkan. Руководство разработчика. Window surface (перевод)
- [Разработка игр, Unreal Engine, Игры и игровые приставки] Боевая система в 9 Monkeys of Shaolin. Как заново изобрести кунг-фу в видеоигре
- [Разработка под iOS, Разработка игр] HexThrees — моя первая законченная игра
- [Разработка игр, Unity, Дизайн игр] Дом в лесу. Работа с освещением в Unity 3D
Теги для поиска: #_razrabotka_igr (Разработка игр), #_godot, #_godot, #_protobuf, #_websocket, #_razrabotka_igr (
Разработка игр
), #_godot
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 23:52
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Что хотим сделать?Синхронизацию действий игроков в игре с клиент-серверной архитектурой. Должна быть возможность играть из браузера.Для примера реализуем простую чат-комнату:
Готовый протофайл можно посмотреть здесь: game.proto
syntax = "proto3";
// Название пакета option java_package = "me.ktori.game.proto"; // Название класса в котором будут находиться подклассы сообщений option java_outer_classname = "GameProto"; //
// Сообщения клиент-сервер // // Запрос на изменение имени 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; } }
Проект после установки аддона 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 Окно GodobufЕсли в прото-файле нет ошибок, то мы увидим сообщение об успешной компиляции и в папке проекта появится нужный скрипт. Отправка сообщенийНастройка сцены СценаВ своей сцене я сделала отдельный контейнер для сообщений и два поля - для ввода текста и имени. Сигналы pressed от кнопок Send и Rename я подключила в скрипт на корневой ноде. Также для вывода сообщений на сцену я сделала функцию show_message, она просто добавляет новый объект Label с текстом сообщения в VBoxContainer, который располагает объекты вертикально.Отправка запросов на серверПосле создания этих полей ввода и кнопок нужно сделать так чтобы они что-то делали.Сперва загрузим получившиеся биндинги в наш скрипт: const GameProto = preload("res://game_proto.gd")
# Изменяем имя на введенное в $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) # Отправляет ClMessage на сервер
func send_msg(msg: GameProto.ClMessage): # Конвертируем ClMessage в PoolByteArray и отправляем его по соединению ws ws.get_peer(1).put_packet(msg.to_bytes()) # Вызывается часто по интервалу
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()) # Хранит 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()])
Нативный клиент WebSocket-клиентЗаключениеВ этой статье рассматриваются только самые основы этого метода. Помимо того что написано здесь, будет важно реализовать:
=========== Источник: habr.com =========== Похожие новости:
Разработка игр ), #_godot |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 23:52
Часовой пояс: UTC + 5