Всем привет! В нескольких статьях я хотел бы поделиться опытом создания подобия ММО игры используя Unreal Engine и Netty. Возможно архитектура и мой опыт кому-то пригодится и поможет начать создавать свой игровой сервер в противовес unreal dedicated server, который слегка прожорлив или заменить собой фреймворки для разработки многопользовательских игр такие как Photon.
В конечном итоге у нас будет клиент, который логиниться или регистрируется в игре, может создавать игровые комнаты, пользоваться чатом и начинать игры, соединение будет зашифровано, клиенты будут синхронизироваться через сервер, в игре будет присутствовать одно оружие — лазер, выстрел будет проверяться на проверочном сервере. Я не стремился сделать красивую графику, тут будет только необходимый минимум, дальнейший функционал добавляется по аналогии. Логику можно легко расширить на сервере, добавить например случайные игры и балансер. Для меня было важно создать ММО базу и разобраться с тем что понадобится для создания полноценной мобильной ММО игры.
- Часть 1. Общая картина, сборка библиотек, подготовка клиента и сервера к обмену сообщениями
- Часть 2. Наращивание игрового функционала
- Часть 3. Бонус материал. HLSL шейдеры в Unreal Engine, генерация ландшафтной сетки с помощью алгоритма Diamond Square, динамическая подгрузка моделей из сети
Общая архитектура, как всё работает
В начале я опишу в общих чертах, а затем мы напишем всё шаг за шагом. Общение клиент сервер построено на сокетах, формат обмена сообщениями Protobuf, каждое сообщение после входа в игру шифруется с помощью алгоритма AES используя библиотеку OpenSSL на клиенте и javax.crypto* на сервере, обмен ключами происходит с помощью протокол Диффи — Хеллмана. В качестве асинхронного сервера используется Netty, данные будем хранить в MySQL и использовать для выборки Hibernate. Я ставил целью поддержку игры на Android, поэтому мы уделим немного внимания портированию под эту платформу. Я назвал проект Spiky — колючий, и не с проста:
As a primarily C++ programmer, Unreal Engine 4 isn't «fun» to develop with.
Если я что то пропустил или что-то не сходится смело обращайтесь к исходникам:
Spiky source code
В конечном счёте вот что у нас получится:
Начнем с того как происходит общение между клиентом и сервером. Оба обладают MessageDecoder и DecryptHandler, это точки входа для сообщений, после чтения пакета, сообщения дешифруются, определяется их тип и по типу отправляются на какой-то обработчик. Точка выхода MessageEncoder и EncryptHandler, клиента и сервера соответственно. Когда мы в Netty отправляем сообщение, оно будет проходить через EncryptHandler. Тут принимается решение нужно ли шифровать, и как обёртывать.
Каждое сообщение, обёртывается в протобаф Wrapper, получатель проверяет что внутри Wrapper, для выбора обработчика, это может быть CryptogramWrapper — шифрованные байты или открытые сообщения. Сообщение Wrapper будет выглядеть примерно так (часть его):
message CryptogramWrapper {
bytes registration = 1;
}
message Wrapper {
Utility utility = 1;
CryptogramWrapper cryptogramWrapper = 2;
}
Весь обмен сообщениями построен на принципе Decoder-Encoder, если нам надо добавить новую команду в игру, нужно обновить условия. Например клиент хочет зарегистрироваться, сообщение попадает в MessageEncoder, где шифруется, обёртывается и отправляется на сервер. На сервере сообщение поступает на DecryptHandler, дешируется если надо, читается тип по наличию у сообщения полей и отправляется на обработку
if(wrapper.hasCryptogramWrapper())
{
if(wrapper.getCryptogramWrapper().hasField(registration_cw))
{
byte[] cryptogram = wrapper.getCryptogramWrapper().getRegistration().toByteArray();
byte[] original = cryptography.Decrypt(cryptogram, cryptography.getSecretKey());
RegModels.Registration registration = RegModels.Registration.parseFrom(original);
new Registration().saveUser(ctx, registration);
}
else if (wrapper.getCryptogramWrapper().hasField(login_cw)) {}
}
Для того чтобы найти поле в сообщении используя .hasField, нам понадобится набор дескрипторов (registration_cw, login_cw) мы их будем хранить отдельно в классе Descriptors.
Итак, если нам нужен новый функционал, то мы
1. Создаём новый тип Protobuf сообщения, вкладываем его в Wrapper/CryptogramWrapper
2. Объявляем поля к которым нужен доступ в дескрипторах клиента и сервера
3. Создаём класс логики в который после определения типа отправляем сообщение
4. Добавляем условие определяющее новый тип в Decode-Encoder клиента и сервера
5. Обрабатываем
Эта ключевой момент который придётся повторять множество раз.
В этом проекте я использовал протокол TCP, конечно лучше писать свою надстроку над UDP, что я и пробовал делать вначале, но всё что у меня выходило, было похоже на TCP единственный минус которого, в моей ситуации, невозможность отключить подтверждение пакетов, TCP ждёт подтверждения, прежде чем продолжить отправку, это создаёт задержки, и добиться пинга меньше 100 будет сложно, если пакет будет потерян при передаче по сети, игра останавливается и ждет, пока пакет не будет доставлен повторно. К сожалению, изменить такое поведение TCP никак нельзя, да и не надо, так как в нем и заключается смысл TCP. Выбор типа сокетов полностью зависит от жанра игры, в играх жанра action важно не то что происходило секунду назад, а важно наиболее актуальное состояние игрового мира. Нам нужно, чтобы данные доходили от клиента к серверу как можно быстрее, и мы не хотим ждать повторной отправки данных. Вот почему не следует использовать TCP для многопользовательских игр.
Но если мы хотим сделать reliable udp нас ждут трудности, нам нужно реализовать упорядоченность, возможность включения отключения подтверждения доставки, контроль загруженности канала, отправку больших сообщений, больше 1400 байт. Action игры должны использовать UDP для тех кто хочет почитать про это подробнее советую начать с этих статей и книги:
Сетевое программирование для разработчиков игр. Часть 1: UDP vs. TCP
Реализация Reliable Udp протокола для .Net
Джошуа Глейзер – Многопользовательские игры. Глава 7 задержки, флуктуация и надёжность.
Мне нужно было надёжное, последовательное соединение, для передачи команд, зашифрованных сообщений и файлов (капча). TCP даёт мне такие возможности из коробки. Для передачи игровых данных, часто обновляемых и не очень важных, таких как перемещение игроков, UDP лучший вариант, я добавил возможность отправки UDP сообщений для полноты и чтобы было с чего начать, но в этом проекте всё общение будет происходить посредством TCP. Возможно стоит использовать TCP и UDP совместно? Однако тогда увеличивается количество потерянных UDP пакетов, так как TCP приоритетнее. UDP остался в области дальнейших улучшений. В этой статье я следую принципу «Done in better when pefect»
В основе сервера лежит Netty, он берет на себя работу с сокетами, реализуя удобную архитектуру. Можно подключить несколько обработчиков для входящих данных. В первом обработчике мы десериализируем входящее сообщение используя ProtobufDecoder, а далее обрабатываем непосредственно игровые данные. При этом можно гибко управлять настройками самой библиотеки, выделять ей необходимое число потоков или памяти. C помощью Netty можно быстро и просто написать любое клиент-серверное приложение, которое будет легко расширяться и масштабироваться. Если для обработки клиентов не хватает одного потока, следует всего лишь передать нужное число потоков в конструктор EventLoopGroup. Если на какой-то стадии развития проекта понадобится дополнительная обработка данных, не нужно переписывать код, достаточно добавить новый обработчик в ChannelPipeline, что значительно упрощает поддержку приложения.
Общая архитектура при использовании Netty у нас выглядит так:
public class ServerInitializer extends ChannelInitializer {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
/* отладка */
//pipeline.addLast(new LoggingHandler(LogLevel.INFO));
/* разворачиваем сообщения */
// Decoders protobuf
pipeline.addLast(new ProtobufVarint32FrameDecoder());
pipeline.addLast(new ProtobufDecoder(MessageModels.Wrapper.getDefaultInstance()));
/* оборачиваем сообщения */
// Encoder protobuf
pipeline.addLast(new ProtobufVarint32LengthFieldPrepender());
pipeline.addLast(new ProtobufEncoder());
/* Соединение закрывается если не было входящих сообщений в течении 30 секунд */
pipeline.addLast(new IdleStateHandler(30, 0, 0));
/* зашифруем исходящее сообщение */
pipeline.addLast(new EncryptHandler());
/* расшифруем входящее сообщение */
pipeline.addLast(new DecryptHandler());
}
}
Плюс такого подхода в том что сервер и обработчики можно разнести по разным машинам получив кластер для расчетов игровых данных, получаем довольно гибкую структуру. Пока нагрузки маленькие можно держать все на одном сервере. При возрастании нагрузки логику можно выделить в отдельную машину.
Для проверки попаданий я создал специальный Unreal Engine клиент, задача которого принимать параметры выстрела, размещать объект в мире, на основе того где он был в момент выстрела, симулировать выстрел возвращая основному серверу информацию о попадании, имя объекта перекрытия, кость если есть, или же что промахнулись.
Начнём с нуля
Я старался писать подробно, но многое вынес под спойлер.
Создадим пустой проект с кодом назовём его Spiky. Первым делом удалим созданный по умолчанию GameMode (это класс, определяющий правила текущей игры, может быть переопределен для каждого конкретного уровня, чем мы далее воспользуемся, существует только один экземпляр GameMode) – удалим Spiky_ClientGameModeBase созданный автоматически. Далее откроем Spiky_Client.Build.cs, это часть Unreal Build System в котором мы подключаем различные модули, сторонние библиотеки а так же настраиваем различные сборочные переменные, по умолчанию начиная с версии 4.16 используется режим SharedPCH (Sharing precompiled headers), а так же Include-What-You-Use (IWYU), позволяющий не включать тяжелые заголовки Engine.h. В предыдущих версиях Unreal Engine большая часть функциональности движка была включена через файлы с заголовком модуля, такие как Engine.h и UnrealEd.h, а время компиляции зависело от того, как быстро эти файлы могли быть скомпилированы через Precompiled Header (PCH). По мере роста движка это стало узким местом.
IWYU Reference Guide
В Spiky_Client.Build.cs мы видим
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
Работает хорошо на быстрых машинах с ssd (для работы с unreal – must have иначе головная боль, еще советую отключить IntelliSense и использовать вместо него VisualAssist) но не обладающим ssd машинам, для удобства и скорости я посоветовал бы переключиться на другой режим, который меньше пишет на диск, что мы и сделаем, включив PCHUsageMode.Default тем самым отключив генерацию Precompiled Header.
Все возможные значения PCHUsage:
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PCHUsage = PCHUsageMode.UseSharedPCHs;
PCHUsage = PCHUsageMode.NoSharedPCHs;
PCHUsage = PCHUsageMode.Default;
Сейчас наш файл содержит следующее:
Spiky_Client.Build.csusing UnrealBuildTool;
public class Spiky_Client : ModuleRules
{
public Spiky_Client(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.Default;
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore" });
PrivateDependencyModuleNames.AddRange(new string[] { });
}
}
В чем отличие PublicDependencyModuleNames от PrivateDependencyModuleNames? В Unreal проектах желательно использовать Source/Public и Source/Private для заголовков-интерфейсов и исходного кода, тогда PublicDependencyModuleNames будут доступны в Public и Private папках, но PrivateDependencyModuleNames будет доступен только в папке Private. Разные другие параметры сборки можно изменить переопределив BuildConfiguration.xml, все параметры можно узнать тут:
Configuring Unreal Build System
Незначительные настройки редактора для удобстваВключим мелкие иконки, отображение frame rate и потребление памяти:
General->Miscellaneous->Performance->Show Frame Rate and Memory
General->User Interface->Use Small Tool Bar Icons
Двигаемся дальше, добавим вне игровой GameMode для экранов логина, регистрации и главного меню.
Добавление SpikyGameModeFile->New C++ Class->Game Mode Base назовём SpikyGameMode, выберем public и создадим папку GameModes. Конечный путь должен выглядеть так:
Spiky/Spiky_Client/Source/Spiky_Client/Public/GameModes
Задачей SpikyGameMode будет создание верной ссылки на мир. Мир это объект верхнего уровня, представляющий карту в которой акторы и компоненты будут существовать и визуализироваться. Позже мы создадим класс DiffrentMix унаследованный от UObject в котором будем управлять интерфейсом, для создания виджетов нужна ссылка на текущий мир, которую из классов UObject получить нельзя, поэтому мы создадим GameMode через который инициализируем DiffrentMix и передадим ему ссылку на мир.
Отдельное слово об интерфейсе, это относится к архитектуре клиента. У нас доступ ко всем виджетам, происходит через синглтон DifferentMix, все виджеты размещаются внутри WidgetsContainer, который нам понадобится чтобы размещать виджеты слоями глубину которых можно задать, корень WidgetsContainer это Canvas к сожалению я не нашел способ изменять порядок виджетов используя Viewport. Это удобно когда нужно например чтобы чат гарантированно был поверх всего остального. Для этого выставляем его виджету максимальную глубину (приоритет) у нас в программе mainMenuChatSlot->SetZOrder(10), однако приоритет может быть любой.
Добавим класс DifferentMix, родитель UObject базовый класс для всех объектов, разместим в новой папке Utils Здесь мы будем хранить ссылки на виджеты, редкие функции для которых создавать свои классы было бы лишним, это синглтон через который мы будем управлять пользовательским интерфейсом.
Добавим SpikyGameInstance производный от UGameInstance класс, универсального UObject, который может хранить любые данные переносимые между уровнями. Он создается при создании игры, и существует до тех пор, пока игра не будет закрыта. Мы будем его использовать для хранения уникальных игровых данные, таких как логин игрока, id игровой сессии, ключ шифрования, так же тут мы запускаем и останавливаем потоки слушающие сокеты, и через него мы будем получать доступ к функциям DifferentMix.
Расположение новых классовSpiky_Client/Source/Spiky_Client/Private/GameModes/SpikyGameMode.h
Spiky_Client/Source/Spiky_Client/Private/Utils/DifferentMix.h
Spiky_Client/Source/Spiky_Client/Private/SpikyGameInstance.h
Spiky_Client/Source/Spiky_Client/Public/GameModes/SpikyGameMode.cpp
Spiky_Client/Source/Spiky_Client/Public/Utils/DifferentMix.cpp
Spiky_Client/Source/Spiky_Client/Public/SpikyGameInstance.cpp
Земетьте, возможно из редактора игра после добавления новых классов откажется собираться, это из-за того что мы переключились на режим который требует наличие #include «Spiky_Client.h» во всех исходных файлах, добавим его вручную и соберем через студию, дальше я не добавляю новый код через редактор, я копирую, редактирую вручную и нажимаю на Spiky_Client.uproject пкм Generate Visual Studio project files.
Вернёмся к редактору, создадим папку Maps и сохраним в ней стандартную карту, назовём её MainMap позже мы разместим на ней вращающегося меха (или выбор игрового персонажа как во многих ММО).
Откроем Project Settings -> Maps & Modes и выставим созданные GameMode/GameInstance/Map как на снимке:
Сетевая часть
С подготовкой всё, начнём писать проект с сетевой части реализуем подключение к серверу, восстановление соединения при его потере, слушатели входящих сообщений и поток проверяющий доступность сервера. Главный объект на клиенте через который мы работаем с сетью, обслуживаем сокеты, будет называться SocketObject производный от UObject, добавим его в папку Net. Так как мы используем сеть, нужно добавить модули «Networking», «Sockets» в Spiky_Client.Build.cs
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "Networking", "Sockets" });
Добавим в заголовок SocketObject деструктор, ряд самоописуемых статических функций и нужные нам инклуды SocketSubsystem и Networking.
SocketObject.h// Copyright (c) 2017, Vadim Petrov - MIT License
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "Networking.h"
#include "SocketSubsystem.h"
#include "SocketObject.generated.h"
/**
* Главный сетевой объект, создаёт сокет, отвечает за подключение-отключение и т.п
*/
UCLASS()
class SPIKY_CLIENT_API USocketObject : public UObject
{
GENERATED_BODY()
~USocketObject();
public:
// tcp
static FSocket* tcp_socket;
// tcp адрес сервера
static TSharedPtr tcp_address;
// состояние соединения
static bool bIsConnection;
// переподключиться если соединение потерянно
static void Reconnect();
// проверить онлайн ли сервер
static bool Alive();
// udp
static FSocket* udp_socket;
// udp адрес сервера
static TSharedPtr udp_address;
// мы не создаём отдельный поток для UDP сокет слушателя, у unreal имеется FUdpSocketReceiver, создадим и делегируем входящие сообщения на ф-ю
static FUdpSocketReceiver* UDPReceiver;
static void Recv(const FArrayReaderPtr& ArrayReaderPtr, const FIPv4Endpoint& EndPt);
static void RunUdpSocketReceiver();
static int32 tcp_local_port;
static int32 udp_local_port;
// инициализируем сокеты когда запускаем игру, в GameInstance
static void InitSocket(FString serverAddress, int32 tcp_local_p, int32 tcp_server_port, int32 udp_local_p, int32 udp_server_port);
};
Теперь в исходниках, начнём с создания сокетов в InitSocket, выделим буфер, назначим локальные порты, мне известны два способа создания сокетов, один из них билдером:
tcp_socket = FTcpSocketBuilder("TCP_SOCKET")
.AsNonBlocking()
.AsReusable()
.WithReceiveBufferSize(BufferSize)
.WithSendBufferSize(BufferSize)
.Build();
Или через ISocketSubsystem:
tcp_socket = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateSocket(NAME_Stream, TEXT("TCP_SOCKET"), false);
Это базовые абстракции различных сокет интерфейсов, специфичных для конкретной платформы. Так как мы задаём адрес где-то в файле конфигурации или коде строкой нам следует привести его в нужный вид, для этого используем FIPv4Address::Parse, после чего подключаемся и вызываем bIsConnection = Alive(); Метод отправляет пустые сообщения серверу, если доходят значит связь есть. Напоследок создадим UDP сокет с помощью FUdpSocketBuilder, итоговый вид InitSocket должен быть таким:
USocketObject::InitSocketvoid USocketObject::InitSocket(FString serverAddress, int32 tcp_local_p, int32 tcp_server_port, int32 udp_local_p, int32 udp_server_port)
{
int32 BufferSize = 2 * 1024 * 1024;
tcp_local_port = tcp_local_p;
udp_local_port = udp_local_p;
// tcp
/* пример FTcpSocketBuilder
tcp_socket = FTcpSocketBuilder("TCP_SOCKET")
.AsNonBlocking() // Socket connect always success. Non blocking you say socket connect dont wait for response (Don?t block) so it will return true.
.AsReusable()
.WithReceiveBufferSize(BufferSize)
.WithSendBufferSize(BufferSize)
.Build();
*/
tcp_socket = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateSocket(NAME_Stream, TEXT("TCP_SOCKET"), false);
tcp_address = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateInternetAddr();
FIPv4Address serverIP;
FIPv4Address::Parse(serverAddress, serverIP);
tcp_address->SetIp(serverIP.Value);
tcp_address->SetPort(tcp_server_port);
tcp_socket->Connect(*tcp_address);
bIsConnection = Alive();
// udp
udp_address = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateInternetAddr();
FIPv4Address::Parse(serverAddress, serverIP);
udp_address->SetIp(serverIP.Value);
udp_address->SetPort(udp_server_port);
udp_socket = FUdpSocketBuilder("UDP_SOCKET")
.AsReusable()
.BoundToPort(udp_local_port)
.WithBroadcast()
.WithReceiveBufferSize(BufferSize)
.WithSendBufferSize(BufferSize)
.Build();
}
Закрываем сокеты и удаляем их в деструкторе
if (tcp_socket != nullptr || udp_socket != nullptr)
{
tcp_socket->Close();
delete tcp_socket;
delete udp_socket;
}
Текущее состояние SocketObject такое:
SocketObject.cpp// Copyright (c) 2017, Vadim Petrov - MIT License
#include "Spiky_Client.h"
#include "SocketObject.h"
FSocket* USocketObject::tcp_socket = nullptr;
TSharedPtr USocketObject::tcp_address = nullptr;
bool USocketObject::bIsConnection = false;
FSocket* USocketObject::udp_socket = nullptr;
TSharedPtr USocketObject::udp_address = nullptr;
FUdpSocketReceiver* USocketObject::UDPReceiver = nullptr;
int32 USocketObject::tcp_local_port = 0;
int32 USocketObject::udp_local_port = 0;
USocketObject::~USocketObject()
{
if (tcp_socket != nullptr || udp_socket != nullptr)
{
tcp_socket->Close();
delete tcp_socket;
delete udp_socket;
}
}
void USocketObject::InitSocket(FString serverAddress, int32 tcp_local_p, int32 tcp_server_port, int32 udp_local_p, int32 udp_server_port)
{
int32 BufferSize = 2 * 1024 * 1024;
tcp_local_port = tcp_local_p;
udp_local_port = udp_local_p;
/*
tcp_socket = FTcpSocketBuilder("TCP_SOCKET")
.AsNonBlocking() // Socket connect always success. Non blocking you say socket connect dont wait for response (Don?t block) so it will return true.
.AsReusable()
.WithReceiveBufferSize(BufferSize)
.WithSendBufferSize(BufferSize)
.Build();
*/
// tcp
tcp_socket = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateSocket(NAME_Stream, TEXT("TCP_SOCKET"), false);
// create a proper FInternetAddr representation
tcp_address = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateInternetAddr();
// parse server address
FIPv4Address serverIP;
FIPv4Address::Parse(serverAddress, serverIP);
// and set
tcp_address->SetIp(serverIP.Value);
tcp_address->SetPort(tcp_server_port);
tcp_socket->Connect(*tcp_address);
// set the initial connection state
bIsConnection = Alive();
// udp
udp_address = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->CreateInternetAddr();
FIPv4Address::Parse(serverAddress, serverIP);
udp_address->SetIp(serverIP.Value);
udp_address->SetPort(udp_server_port);
udp_socket = FUdpSocketBuilder("UDP_SOCKET")
.AsReusable()
.BoundToPort(udp_local_port)
.WithBroadcast()
.WithReceiveBufferSize(BufferSize)
.WithSendBufferSize(BufferSize)
.Build();
}
void USocketObject::RunUdpSocketReceiver()
{
}
void USocketObject::Recv(const FArrayReaderPtr& ArrayReaderPtr, const FIPv4Endpoint& EndPt)
{
}
void USocketObject::Reconnect()
{
}
bool USocketObject::Alive()
{
return false;
}
Займемся методом отправкой Alive сообщений, форматом сообщений и сервером. В основе сервера я использовал ассинхронный фреймворк Netty написанный на java. Основное преимущество которого проста чтения и записи в сокеты. Netty поддерживает неблокирующий асинхронный ввод-вывод, легко масштабируется, что важно для онлайн игры, если ваша система должна иметь возможность обрабатывать многие тысячи соединений одновременно. И что тоже важно — Netty легко использовать.
Создадим сервер, тут пользуемся IntelliJ IDEA, создаём Maven проект:
com.spiky.server
Spiky server
Добавляем необходимые нам зависимости, Netty
io.netty
netty-all
4.1.8.Final
Теперь разберёмся с форматом сериализации сообщений. Мы используем Protobuf. Размер сообщения выходит предельно малым, и судя по графикам он во всем превосходит JSON.
Сравнение производительности
*взято
отсюда, хороший материал, с примерами протобафа и разными метриками
Для того чтобы определить структуру сериализуемых данных, необходимо создать .proto-файл с исходным кодом этой структуры например:
syntax = "proto3";
message Player {
string player_name = 1;
string team = 2;
int32 health = 3;
PlayerPosition playerPosition = 4;
}
message PlayerPosition {}
После чего эта структура данных, компилируется в классы специальным компилятором, protoc, команда компиляции выглядит так:
./protoc --cpp_out=. --java_out=. GameModels.proto
У протобафа хорошая
документация которая лучше поможет понять значение каждого поля.
Протобаф реализован для Java и C++ используемым нашим проектом. Добавим еще одну зависимость:
com.google.protobuf
protobuf-java
3.0.0-beta-4
Теперь нужно добавить поддержку протобафа в Unreal это уже не так просто, для начала получаем
ветку с github. Теперь нужно правильно собрать, инструкцию как собрать через Visual Studio можно найти
тут. Выставить тип линковки для анриала «Filter through to Configuration Properties > C/C++ > Code Generation > Runtime Library, from the drop down list select Multi-threaded DLL (/MD)»
смотрите Linking Static Libraries Using The Build System и собрать libprotobuf.lib. После добавим в проект, создадим в корне папку ThirdParty/Protobuf в которой нужно создать Libs и Includes. Поместить /protobuf-3.0.0-beta-4/cmake/build/solution/Release/libprotobuf.lib в Libs. Поместить /proto-install/include/google в Includes.
Так как моя цель была в поддержке мобильных устройств, нам понадобится собрать библиотеку еще и для Android с помощью Android NDK,
список файлов для компиляции можно взять тут, в начале lite, потом остальное. Сам процесс выглядит так, установите Android NDK, создайте папку jni поместите в них два файла Android.mk и Application.mk, там же создайте src в которую скопируйте src из protobuf-3.0.0-beta-4/src и воспользуйтесь ndk-build. Готовые файлы Application.mk и Android.mk:
Application.mkAPP_OPTIM := release
APP_ABI := armeabi-v7a #x86 x86_64
APP_STL := gnustl_static
NDK_TOOLCHAIN_VERSION := clang
APP_CPPFLAGS += -D GOOGLE_PROTOBUF_NO_RTTI=1
APP_CPPFLAGS += -D __ANDROID__=1
APP_CPPFLAGS += -D HAVE_PTHREAD=1
Android.mkLOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := libprotobuf
LOCAL_SRC_FILES :=\
src/google/protobuf/arena.cc \
src/google/protobuf/arenastring.cc \
src/google/protobuf/extension_set.cc \
src/google/protobuf/generated_message_util.cc \
src/google/protobuf/io/coded_stream.cc \
src/google/protobuf/io/zero_copy_stream.cc \
src/google/protobuf/io/zero_copy_stream_impl_lite.cc \
src/google/protobuf/message_lite.cc \
src/google/protobuf/repeated_field.cc \
src/google/protobuf/stubs/bytestream.cc \
src/google/protobuf/stubs/common.cc \
src/google/protobuf/stubs/int128.cc \
src/google/protobuf/stubs/once.cc \
src/google/protobuf/stubs/status.cc \
src/google/protobuf/stubs/statusor.cc \
src/google/protobuf/stubs/stringpiece.cc \
src/google/protobuf/stubs/stringprintf.cc \
src/google/protobuf/stubs/structurally_valid.cc \
src/google/protobuf/stubs/strutil.cc \
src/google/protobuf/stubs/time.cc \
src/google/protobuf/wire_format_lite.cc \
src/google/protobuf/any.cc \
src/google/protobuf/any.pb.cc \
src/google/protobuf/api.pb.cc \
src/google/protobuf/compiler/importer.cc \
src/google/protobuf/compiler/parser.cc \
src/google/protobuf/descriptor.cc \
src/google/protobuf/descriptor.pb.cc \
src/google/protobuf/descriptor_database.cc \
src/google/protobuf/duration.pb.cc \
src/google/protobuf/dynamic_message.cc \
src/google/protobuf/empty.pb.cc \
src/google/protobuf/extension_set_heavy.cc \
src/google/protobuf/field_mask.pb.cc \
src/google/protobuf/generated_message_reflection.cc \
src/google/protobuf/io/gzip_stream.cc \
src/google/protobuf/io/printer.cc \
src/google/protobuf/io/strtod.cc \
src/google/protobuf/io/tokenizer.cc \
src/google/protobuf/io/zero_copy_stream_impl.cc \
src/google/protobuf/map_field.cc \
src/google/protobuf/message.cc \
src/google/protobuf/reflection_ops.cc \
src/google/protobuf/service.cc \
src/google/protobuf/source_context.pb.cc \
src/google/protobuf/struct.pb.cc \
src/google/protobuf/stubs/mathlimits.cc \
src/google/protobuf/stubs/substitute.cc \
src/google/protobuf/text_format.cc \
src/google/protobuf/timestamp.pb.cc \
src/google/protobuf/type.pb.cc \
src/google/protobuf/unknown_field_set.cc \
src/google/protobuf/util/field_comparator.cc \
src/google/protobuf/util/field_mask_util.cc \
src/google/protobuf/util/internal/datapiece.cc \
src/google/protobuf/util/internal/default_value_objectwriter.cc \
src/google/protobuf/util/internal/error_listener.cc \
src/google/protobuf/util/internal/field_mask_utility.cc \
src/google/protobuf/util/internal/json_escaping.cc \
src/google/protobuf/util/internal/json_objectwriter.cc \
src/google/protobuf/util/internal/json_stream_parser.cc \
src/google/protobuf/util/internal/object_writer.cc \
src/google/protobuf/util/internal/proto_writer.cc \
src/google/protobuf/util/internal/protostream_objectsource.cc \
src/google/protobuf/util/internal/protostream_objectwriter.cc \
src/google/protobuf/util/internal/type_info.cc \
src/google/protobuf/util/internal/type_info_test_helper.cc \
src/google/protobuf/util/internal/utility.cc \
src/google/protobuf/util/json_util.cc \
src/google/protobuf/util/message_differencer.cc \
src/google/protobuf/util/time_util.cc \
src/google/protobuf/util/type_resolver_util.cc \
src/google/protobuf/wire_format.cc \
src/google/protobuf/wrappers.pb.cc
LOCAL_CPPFLAGS := -std=c++11
LOCAL_LDLIBS := -llog
ifeq ($(TARGET_ARCH),x86)
LOCAL_SRC_FILES := $(LOCAL_SRC_FILES) \
src/google/protobuf/stubs/atomicops_internals_x86_gcc.cc
endif
ifeq ($(TARGET_ARCH),x86_64)
LOCAL_SRC_FILES := $(LOCAL_SRC_FILES) \
src/google/protobuf/stubs/atomicops_internals_x86_gcc.cc
endif
LOCAL_C_INCLUDES = $(LOCAL_PATH)/src
include $(BUILD_SHARED_LIBRARY)
В случае успеха мы получим «сошку» /android/proto/libs/armeabi-v7a — libprotobuf.so. Скопируем её в проект /Spiky/Spiky_Client/Source/Spiky_Client/armv7.
Возможные трудности и ошибкиЕсли появляется ошибка:
ThirdParty/Protobuf/Includes\google/protobuf/arena.h(635,25) : error: cannot use typeid with -fno-rtti
откройте arena.h и напишите в самом вверху
#define GOOGLE_PROTOBUF_NO_RTTI
Если после включения в заголовков наших сообщений, возникает конфликт имён протобафа и анриала —
error: "error C3861: 'check': identifier not found
, проблема в совпадении имён макроса check в анриал (AssertionMacros.h), и check в протобафе (type_traits.h), к счастью check в протобафе используется очень мало и проблему легко решить подредактировав исходники, переименовав check в check_UnrealFix, например, и закомментировать #undef check. Решение подсказал вопрос на unreal answers —
Error C3861 (identifier not found) when including protocol buffers.
template
struct is_base_of {
typedef char (&yes)[1];
typedef char (&no)[2];
// BEGIN GOOGLE LOCAL MODIFICATION -- check is a #define on Mac.
#undef check
// END GOOGLE LOCAL MODIFICATION
static yes check(const B*);
static no check(const void*);
enum {
value = sizeof(check(static_cast(NULL))) == sizeof(yes),
};
};
Исправленный вариант type_traits.h выглядит так:
template
struct is_base_of {
typedef char (&yes)[1];
typedef char (&no)[2];
// BEGIN GOOGLE LOCAL MODIFICATION -- check is a #define on Mac.
//#undef check
// END GOOGLE LOCAL MODIFICATION
static yes check_UnrealFix(const B*);
static no check_UnrealFix(const void*);
enum {
value = sizeof(check_UnrealFix(static_cast(NULL))) == sizeof(yes),
};
};
Вообще часто встречаются проблемы совместимости, мы еще с ними столкнёмся когда будем добавлять поддержку OpenSSL и компилировать под андроид. к примеру Android NDK не полностью поддерживает С++ 11, мне нужно было получить миллисекунды я хотел использовать chrono но увы, нужно часто проводить проверки, здесь куча подводных камней.
Советую тестировать функционал внешних библиотек, перед добавлением в проект, отдельно, вне Unreal, это значительно быстрее.
Пока отложим подключение protobuf, скомпилируем OpenSSL чтобы больше не возвращаться к этой теме и не повторяться. Я использую OpenSSL-1.0.2k. Чтобы собрать библиотеку, воспользуйтесь этим руководством (
Building the 64-bit static libraries with debug symbols). Пару советов если возникнут трудности:
- Найди в папке со студией ml64.exe и скопировать в папку с OpenSSL, не пользуемся NASM — это только для х32
- Используйте чистые исходники (без попыток сборки)
openssl fatal error LNK1112: module machine type 'x64' conflicts with target machine type 'X86'
— откройте Developer Command Prompt for VS2015, перейдите E:\Program Files (x86)\Microsoft Visual Studio 14.0\VC и выполните vcvarsall.bat x64 (источник)
- Конфликт имён с Unreal закомментируйте 172 строчку:
openssl/ossl_typ.h(172): error C2365: 'UI': redefinition; previous definition was 'namespace'
Что до компиляции под андроид, проще всего это делать из под Ubuntu, воспользовавшись скриптами для armv7 и x86 которые вы можете найти в исходниках проекта.
OpenSSL Android
How to add a shared library (.so) in android project
Решение возможных проблемПосле того как мы получили сошку, с ней нужно немного поработать, надо изменить номер версии иначе будем получать ошибку:
E/AndroidRuntime( 1574): java.lang.UnsatisfiedLinkError: dlopen failed: could not load library "libcrypto.so.1.0.0" needed by "libUE4.so"; caused by library "libcrypto.so.1.0.0" not found
Где то внутри
зашит номер версии, воспользуемся улитой Ubuntu для переименования:
rpl -R -e .so.1.0.0 "_1_0_0.so" /path/to/libcrypto.so
Скопируем сошку в Source/Spiky_Client/armv7, библиотеки, заголовки в ThirdParty/OpenSSL и скомпилируем.
Подключаем библиотеки в Spiky_Client.Build.cs. Для удобства добавим две функциии ModulePath и ThirdPartyPath, первая возвращает путь к проекту, вторая к папке с подключаемыми библиотеками.
public class Spiky_Client : ModuleRules
{
private string ModulePath
{
get { return ModuleDirectory; }
}
private string ThirdPartyPath
{
get { return Path.GetFullPath(Path.Combine(ModulePath, "../../ThirdParty/")); }
}
...
}
Специфично для каждой платформы мы добавляем библиотеку и заголовки. При компиляции выбирается необходимая платформе библиотека:
Spiky_Client.Build.cs// Copyright (c) 2017, Vadim Petrov - MIT License
using UnrealBuildTool;
using System.IO;
using System;
public class Spiky_Client : ModuleRules
{
private string ModulePath
{
get { return ModuleDirectory; }
}
private string ThirdPartyPath
{
get { return Path.GetFullPath(Path.Combine(ModulePath, "../../ThirdParty/")); }
}
public Spiky_Client(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.Default;
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "Networking", "Sockets" });
PrivateDependencyModuleNames.AddRange(new string[] { "UMG", "Slate", "SlateCore" });
string IncludesPath = Path.Combine(ThirdPartyPath, "Protobuf", "Includes");
PublicIncludePaths.Add(IncludesPath);
IncludesPath = Path.Combine(ThirdPartyPath, "OpenSSL", "Includes");
PublicIncludePaths.Add(IncludesPath);
if ((Target.Platform == UnrealTargetPlatform.Win64))
{
string LibrariesPath = Path.Combine(ThirdPartyPath, "Protobuf", "Libs");
PublicAdditionalLibraries.Add(Path.Combine(LibrariesPath, "libprotobuf.lib"));
LibrariesPath = Path.Combine(ThirdPartyPath, "OpenSSL", "Libs");
PublicAdditionalLibraries.Add(Path.Combine(LibrariesPath, "libeay32.lib"));
}
if (Target.Platform == UnrealTargetPlatform.Android)
{
string BuildPath = Utils.MakePathRelativeTo(ModuleDirectory, BuildConfiguration.RelativeEnginePath);
AdditionalPropertiesForReceipt.Add(new ReceiptProperty("AndroidPlugin", Path.Combine(BuildPath, "APL.xml")));
PublicAdditionalLibraries.Add(BuildPath + "/armv7/libprotobuf.so");
PublicAdditionalLibraries.Add(BuildPath + "/armv7/libcrypto_1_0_0.so");
}
}
}
Чтобы добавить сошки в сборку нужно создать APL.xml (AndroidPluginLanguage) файл в папке с исходниками, в котором описывается откуда и куда должны быть скопированы библиотеки, и под какую платформу armv7, x86. Примеры и другие параметры можно
глянуть тут.
Можно протестировать работу OpenSSL для windows и android создав тестовый hud и вывести в него hash (в исходниках отсутствует)// OpenSSL tests
#include evp.h>
#include
#include
void ADebugHUD::DrawHUD()
{
Super::DrawHUD();
FString hashTest = "Hash test (sha256): " + GetSHA256_s("test", strlen("test"));
DrawText(hashTest, FColor::White, 50, 50, HUDFont);
}
FString ADebugHUD::GetSHA256_s(const void * data, size_t data_len)
{
EVP_MD_CTX mdctx;
unsigned char md_value[EVP_MAX_MD_SIZE];
unsigned int md_len;
EVP_DigestInit(&mdctx, EVP_sha256());
EVP_DigestUpdate(&mdctx, data, (size_t)data_len);
EVP_DigestFinal_ex(&mdctx, md_value, &md_len);
EVP_MD_CTX_cleanup(&mdctx);
std::stringstream s;
s.fill('0');
for (size_t i = 0; i < md_len; ++i)
s << std::setw(2) << std::hex << (unsigned short)md_value[i];
return s.str().c_str();
}
Когда мы добавляем скомпилированные .proto сообщения, анриал выдаёт различные предупреждения, отключить которые можно либо разбираясь с исходникам движка, либо подавить их. Для этого создадим DisableWarnings.proto и скомпилируем
./protoc --cpp_out=. --java_out=. DisableWarnings.proto
затем в полученном заголовке DisableWarnings.pb.h подавим предупреждения, будем включать DisableWarnings в каждый прото файл. В DisableWarnings.proto всего три строчки, версия протобафа, имя java пакета и имя генерируемого класса.
#define PROTOBUF_INLINE_NOT_IN_HEADERS 0
#pragma warning(disable:4100)
#pragma warning(disable:4127)
#pragma warning(disable:4125)
#pragma warning(disable:4267)
#pragma warning(disable:4389)
DisableWarnings.protosyntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "DisableWarnings";
DisableWarnings.pb.h// Generated by the protocol buffer compiler. DO NOT EDIT!
// source: DisableWarnings.proto
#define PROTOBUF_INLINE_NOT_IN_HEADERS 0
#pragma warning(disable:4100)
#pragma warning(disable:4127)
#pragma warning(disable:4125)
#pragma warning(disable:4267)
#pragma warning(disable:4389)
#ifndef PROTOBUF_DisableWarnings_2eproto__INCLUDED
#define PROTOBUF_DisableWarnings_2eproto__INCLUDED
#include
#include protobuf/stubs/common.h>
#if GOOGLE_PROTOBUF_VERSION < 3000000
#error This file was generated by a newer version of protoc which is
#error incompatible with your Protocol Buffer headers. Please update
#error your headers.
#endif
#if 3000000 < GOOGLE_PROTOBUF_MIN_PROTOC_VERSION
#error This file was generated by an older version of protoc which is
#error incompatible with your Protocol Buffer headers. Please
#error regenerate this file with a newer version of protoc.
#endif
#include protobuf/arena.h>
#include protobuf/arenastring.h>
#include protobuf/generated_message_util.h>
#include protobuf/metadata.h>
#include protobuf/repeated_field.h>
#include protobuf/extension_set.h>
// @@protoc_insertion_point(includes)
// Internal implementation detail -- do not call these.
void protobuf_AddDesc_DisableWarnings_2eproto();
void protobuf_AssignDesc_DisableWarnings_2eproto();
void protobuf_ShutdownFile_DisableWarnings_2eproto();
// ===================================================================
// ===================================================================
// ===================================================================
#if !PROTOBUF_INLINE_NOT_IN_HEADERS
#endif // !PROTOBUF_INLINE_NOT_IN_HEADERS
// @@protoc_insertion_point(namespace_scope)
// @@protoc_insertion_point(global_scope)
#endif // PROTOBUF_DisableWarnings_2eproto__INCLUDED
Все наши протобафы мы помещаем в папку Protobufs (Source/Spiky_Client/Protobufs), но лучше настроить автоматичекое размещение сгенерированных файлов, указав полные пути в --cpp_out=. --java_out=.
Едем дальше, настроим Spiky сервер!
Создаём пакет com.spiky.server и добавляем класс ServerMain, входная точка нашего сервера, тут мы будем хранить глобальные переменные, инициализируем и запустим два Netty сервера для tcp и udp соединений (но напомню в проекте используется только tcp). Нам определённо понадобится файл конфигурации, где мы могли бы хранить порты серверов (сервера логики – Netty и проверочного на Unreal), а так же возможность включать отключать криптографию. В папке Recources создадим configuration.properties.
Добавим в ServerMain инициализацию сервера, и чтение файла настроек:
/* файл конфигурации */
private static final ResourceBundle configurationBundle = ResourceBundle.getBundle("configuration", Locale.ENGLISH);
/* серверные порты */
private static final int tcpPort = Integer.valueOf(configurationBundle.getString("tcpPort"));
private static final int udpPort = Integer.valueOf(configurationBundle.getString("udpPort"));
private static void run_tcp() {
EventLoopGroup bossGroup = new NioEventLoopGroup(); // 1
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap(); // 2
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class) // 3
.childHandler(new com.spiky.server.tcp.ServerInitializer()) // 4
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.TCP_NODELAY, true);
ChannelFuture f = b.bind(tcpPort).sync(); // 5
f.channel().closeFuture().sync(); // 6
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}
Полный файл, с инициализацией udp и main()/*
* Copyright (c) 2017, Vadim Petrov - MIT License
*/
package com.spiky.server;
import io.netty.bootstrap.Bootstrap;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioDatagramChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import java.util.Locale;
import java.util.ResourceBundle;
public class ServerMain {
/* файл конфигурации */
private static final ResourceBundle configurationBundle = ResourceBundle.getBundle("configuration", Locale.ENGLISH);
/* серверные порты */
private static final int tcpPort = Integer.valueOf(configurationBundle.getString("tcpPort"));
private static final int udpPort = Integer.valueOf(configurationBundle.getString("udpPort"));
private static void run_tcp() {
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new com.spiky.server.tcp.ServerInitializer())
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.TCP_NODELAY, true);
ChannelFuture f = b.bind(tcpPort).sync();
f.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}
private static void run_udp() {
final NioEventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group).channel(NioDatagramChannel.class)
.handler(new com.spiky.server.udp.ServerInitializer());
bootstrap.bind(udpPort).sync();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
new Thread(ServerMain::run_tcp).start();
new Thread(ServerMain::run_udp).start();
}
}
- NioEventLoopGroup — это многопоточный цикл, который обрабатывает операции ввода-вывода. Netty предоставляет различные реализации EventLoopGroup для разных видов транспорта. В этом примере мы реализуем серверное приложение, и поэтому будет использоваться две NioEventLoopGroup. Первый, часто называемое «босс», принимает входящее соединение. Второй, часто называемый «рабочий», обрабатывает трафик принятого соединения, босс принимает соединение и регистрирует принятое соединение с работником. Сколько потоков используется и как они сопоставляются с создаваемыми каналами, зависит от реализации EventLoopGroup и может настраиваться через конструктор
- ServerBootstrap — это вспомогательный класс, который устанавливает сервер. Вы можете настроить сервер напрямую с помощью канала. Однако учтите, что это утомительный процесс, и вам не нужно делать это в большинстве случаев
- Здесь мы указываем использовать класс NioServerSocketChannel, который используется для создания нового канала приема входящих соединений
- Специальный Handler, который предоставляет простой способ инициализации канала после его регистрации в EventLoop. В нем мы добавляем обработчики входящих сообщений, декодеры, инкодеры и логику,
- Привязываем и начнинаем принимать входящие соединения
- Подождём, пока серверный сокет не будет закрыт
Как работает Netty, на простых примерах эхо сервера, с объясненями можно найти
в документации. Еще очень советую прочитать книгу Netty in Action, она небольшая.
Наш сервер почти готов к запуску, добавим ServerInitializer для обоих протоколов:
/* Для UDP и TCP*/
public class ServerInitializer extends ChannelInitializer
public class ServerInitializer extends ChannelInitializer
Создадим два пакета
com.spiky.server.tcp
и
com.spiky.server.udp
в каждом из которых создадим класс ServerInitializer (с отличными NioDatagramChannel/SocketChannel) с таким содержимым:
package com.spiky.server.tcp;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
public class ServerInitializer extends ChannelInitializer {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
}
}
Pipeline это то через что проходит каждое сообщение, содержит список ChannelHandlers, который обрабатывают входящие и исходящие сообщения. Например один из обработчиков может принимать только строковые данные, другой протобаф, если мы вызовем write(string) то вызовется обработчик для строк, в котором мы решим обрабатывать сообщение дальше, отправить в другой обработчик соответствующий новому типу или отправить клиенту. У каждого обработчика есть тип определяющий для каких он сообщений — входящих или исходящих.
Добавим стандартный обработчик отладки в ServerInitializer, который весьма полезен, можно посмотреть размер входящих сообщений и в каком виде они представлены, так же адресат:
...
ChannelPipeline pipeline = ch.pipeline();
/* отладка */
pipeline.addLast(new LoggingHandler(LogLevel.INFO));
...
Обработка протобаф сообщений присланных по TCP отличается от присланных по UDP, у Netty есть заготовленные обработчики для протобафа, но работают они только для потоковых соединений таких как TCP, когда мы отправляем сообщение мы должны знать где закончить читать, поэтому в начале каждого сообщения должна идти его длина, затем само тело. Начнём с UDP, добавим и протестируем прием и отправку сообщений сервером и клиентом. Добавим обработчик отладки в ServerInitializer, затем создадим пакет com.spiky.server.udp.handlers. Добавим в него public class ProtoDecoderHandler extends SimpleChannelInboundHandler. ChannelInboundHandlerAdapter, позволяет явным образом обрабатывать только определенные типы входящих сообщений. Например ProtoDecoderHandler обрабатывает только сообщения типа DatagramPacket.
Добавим сюда же PackageHandler — класс с логикой, после декодирования (а далее нам надо будет декодировать и расшифровать) сюда приходят сообщения используемого нами протобаф формата public class PackageHandler extends SimpleChannelInboundHandler
MessageModels это класс-обёртка верхнего уровня, который будет содержать шифрованные и нешифрованные данные. Все сообщения обёртывается в него, вот его конечный вид, некоторые типы нам еще не знакомы:
message Wrapper {
Utility utility = 1;
InputChecking inputChecking = 2;
Registration registration = 3;
Login login = 4;
CryptogramWrapper cryptogramWrapper = 5;
}
Когда мы отправляем сообщение, принимающая сторона читает обёртку и смотрит какие у неё есть поля. Логина, регистрация? А может зашифрованные байты cryptogramWrapper? Тем самым выбирая поток исполнения.
Давайте определим и опишем все протобаф модели в нашем проекте чтобы больше на это не отвлекаться.
DisableWarnings — пустой протобаф, задача которого лишь в том чтобы отключать предупреждения.
DisableWarnings.protosyntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "DisableWarnings";
MessageModels — содержит в себе главную обёртку Wrapper, внутри которой могут быть нешифрованные сообщения Utility, InputChecking, Registration, Login и шифрованные CryptogramWrapper. CryptogramWrapper содержит зашифрованные байты, например после того как мы обменялись ключами и начали шифровать данные, эти данные присваиваются как одно из полей CryptogramWrapper. Получатель получил, проверил есть ли зашифрованные данные, расшифровал, определил тип по имени поля и отправил дальше на обработку.
MessageModels.proto
syntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "MessageModels";
import "UtilityModels.proto";
import "RegLogModels.proto";
import "DisableWarnings.proto";
message CryptogramWrapper {
bytes registration = 1;
bytes login = 2;
bytes initialState = 3;
bytes room = 4;
bytes mainMenu = 5;
bytes gameModels = 6;
}
message Wrapper {
Utility utility = 1;
InputChecking inputChecking = 2;
Registration registration = 3;
Login login = 4;
CryptogramWrapper cryptogramWrapper = 5;
}
UtilityModels — единственная задача этой модели отправлять alive сообщения.
UtilityModels.protosyntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "UtilityModels";
import "DisableWarnings.proto";
message Utility {
bool alive = 1;
}
RegLogModels — содержит модели необходимые для регистрации и входа, а так же проверки пользовательского ввода и получении капчи с сервера.
RegLogModels.proto
syntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "RegistrationLoginModels";
import "DisableWarnings.proto";
import "GameRoomModels.proto";
message InputChecking {
string login = 1;
string mail = 2;
string captcha = 3;
bool getCaptcha = 4;
bytes captchaData = 5;
oneof v1 {
bool loginCheckStatus = 6;
bool mailCheckStatus = 7;
bool captchaCheckStatus = 8;
}
}
message Login {
string mail = 1;
string hash = 2;
string publicKey = 3;
oneof v1 {
int32 stateCode = 4;
}
}
message Registration {
string login = 1;
string hash = 2;
string mail = 3;
string captcha = 4;
string publicKey = 5;
oneof v1 {
int32 stateCode = 6;
}
}
message InitialState {
string sessionId = 1;
string login = 2;
repeated CreateRoom createRoom = 3;
}
MainMenuModels — данные необходимые нам в главном меню, здесь только чат.
MainMenuModels.proto
syntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "MainMenuModels";
import "DisableWarnings.proto";
message ChatMessage {
int64 time = 1;
string name = 2;
string text = 3;
}
message Chat {
int64 time = 1;
string name = 2;
string text = 3;
oneof v1 {
bool subscribe = 4;
}
repeated ChatMessage messages = 5;
}
message MainMenu {
Chat chat = 1;
}
GameRoomModels — всё что надо для создания и обновления игровых комнат.
GameRoomModels.proto
syntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "GameRoomModels";
import "DisableWarnings.proto";
import "MainMenuModels.proto";
message Room {
CreateRoom createRoom = 1;
RoomsListUpdate roomsListUpdate = 2;
SubscribeRoom subscribeRoom = 3;
RoomUpdate roomUpdate = 4;
bool startGame = 5;
string roomName = 6;
}
message CreateRoom {
string roomName = 1;
string mapName = 2;
string gameTime = 3;
string maxPlayers = 4;
string creator = 5;
}
message RoomsListUpdate {
bool deleteRoom = 1;
bool addRoom = 2;
string roomName = 3;
string roomOwner = 4;
}
message SubscribeRoom {
oneof v1 {
bool subscribe = 1;
}
string roomName = 2;
int32 stateCode = 3;
RoomDescribe roomDescribe = 4;
string player = 5;
string team = 6;
}
message RoomDescribe {
repeated TeamPlayer team1 = 1;
repeated TeamPlayer team2 = 2;
repeated TeamPlayer undistributed = 3;
string roomName = 4;
string mapName = 5;
string gameTime = 6;
string maxPlayers = 7;
string creator = 8;
Chat chat = 9;
}
message TeamPlayer {
string player_name = 1;
}
message RoomUpdate {
RoomDescribe roomDescribe = 1;
string targetTeam = 2;
string roomName = 3;
}
GameModels — модель для игры, позиция игрока, параметры выстрела, начальное состояние, пинг.
GameModels.proto
syntax = "proto3";
option java_package = "com.spiky.server.protomodels";
option java_outer_classname = "GameModels";
import "DisableWarnings.proto";
message GameInitialState {
bool startGame = 1;
repeated Player player = 2;
}
message Player {
string player_name = 1;
string team = 2;
int32 health = 3;
PlayerPosition playerPosition = 4;
}
message PlayerPosition {
Location loc = 1;
Rotation rot = 2;
message Location {
int32 X = 1;
int32 Y = 2;
int32 Z = 3;
}
message Rotation {
int32 Pitch = 1;
int32 Roll = 2;
int32 Yaw = 3;
}
string playerName = 3;
int64 timeStamp = 4;
}
message Ping {
int64 time = 1;
}
message Shot {
Start start = 1;
End end = 2;
PlayerPosition playerPosition = 3;
message Start {
int32 X = 1;
int32 Y = 2;
int32 Z = 3;
}
message End {
int32 X = 1;
int32 Y = 2;
int32 Z = 3;
}
int64 timeStamp = 4;
string requestFrom = 5;
string requestTo = 6;
string roomOwner = 7;
oneof v1 {
bool result_hitState = 8;
}
string result_bonename = 9;
}
message GameData {
GameInitialState gameInitialState = 1;
PlayerPosition playerPosition = 2;
Ping ping = 3;
Shot shot = 4;
}
Все модели вы можете найти в Spiky/Spiky_Protospace.
Чтобы определить тип сообщения и как оно должно обрабатываться, мы узнаём что в нем по наличию именованных полей:
// java
if(wrapper.getCryptogramWrapper().hasField(registration_cw)) //сделать что-то
// cpp
if (wrapper->cryptogramwrapper().GetReflection()->HasField(wrapper->cryptogramwrapper(), Descriptors::registration_cw)) //сделать что-то
И чтобы не захламлять код создадим отдельные классы с набором дескрипторов, добавьте на клиенте и на сервере в Utils класс Descriptors.
Descriptors.java// Copyright (c) 2017, Vadim Petrov - MIT License
package com.spiky.server.utils;
import com.spiky.server.protomodels.*;
/**
* Разные дескрипторы для того чтобы определить содержимое сообщений
* */
public class Descriptors {
public static com.google.protobuf.Descriptors.FieldDescriptor registration_cw = MessageModels.CryptogramWrapper.getDefaultInstance().getDescriptorForType().findFieldByName("registration");
public static com.google.protobuf.Descriptors.FieldDescriptor login_cw = MessageModels.CryptogramWrapper.getDefaultInstance().getDescriptorForType().findFieldByName("login");
public static com.google.protobuf.Descriptors.FieldDescriptor initialState_cw = MessageModels.CryptogramWrapper.getDefaultInstance().getDescriptorForType().findFieldByName("initialState");
public static com.google.protobuf.Descriptors.FieldDescriptor room_cw = MessageModels.CryptogramWrapper.getDefaultInstance().getDescriptorForType().findFieldByName("room");
public static com.google.protobuf.Descriptors.FieldDescriptor mainMenu_cw = MessageModels.CryptogramWrapper.getDefaultInstance().getDescriptorForType().findFieldByName("mainMenu");
public static com.google.protobuf.Descriptors.FieldDescriptor gameModels_cw = MessageModels.CryptogramWrapper.getDefaultInstance().getDescriptorForType().findFieldByName("gameModels");
public static com.google.protobuf.Descriptors.FieldDescriptor getCaptcha_ich = RegistrationLoginModels.InputChecking.getDefaultInstance().getDescriptorForType().findFieldByName("getCaptcha");
public static com.google.protobuf.Descriptors.FieldDescriptor login_ich = RegistrationLoginModels.InputChecking.getDefaultInstance().getDescriptorForType().findFieldByName("login");
public static c
Метки:
author Cyborg
разработка игр
unreal engine
open source
java
c++
netty
ue4
client
server
mmo
-
Запись понравилась
-
0
Процитировали
-
0
Сохранили
-