Кооператив на Unity за «Бесплатно», или p2p соединение через ISteamNetworkingMessages
Разрабатывая вторую игру на Unity я решил замахнуться на кооперативный режим. Так как новая игра тоже выйдет на площадке Steam, сервисы стима уже интегрированны, а взнос за приложение уже уплачен, было решено попробовать сетевые сервисы стима. Steam заявляет что они очень круто работают, сервера расположены по всему миру (спойлер, это не так), работать с ними просто, а главное работает всё быстро.
Так как использовать сервер я не хочу, мне больше всего подходит вариант p2p соединения, и у Steam такое есть (даже два).
Как я уже сказал, у Steam есть два сетевых интерфейса ориентированных на p2p. Первый называется ISteamNetworking, в документации стим пишет что он устарел, и его уже даже удалять хотят. Я разумеется этой строки не заметил, и сначала написал все на этом интерфейсе. Кстати, про него я нашел пару англоязычных статей.
Актуальный интерфейс называется ISteamNetworkingMessages. Работает на UDP (точнее поверх ISteamNetworkingSockets). И пересылает все пакеты через ближайший стимовский сервер (из за этого, кстати, есть некоторые проблемы с пингом).
Собственно, главная проблема этого интерфейса, это практически полное отсутствие информации, за исключением документации Steam. Собственно, поэтому я и решил написать эту статью.
Наконец к практике
Первым делом вам надо сделать какое-то лобби, чтобы получить SteamID будущих игроков. О создании лобби уже до меня написано куча статей, поэтому на этом не буду заострять внимание.
У меня лобби выглядит примерно так
Для работы понадобится какая-то структура, которая будет содержать передаваемую информацию. У меня используется класс с названием Package, в котором просто написана куча конверторов, в том числе и в структуру. Эту структуру мы в дальнейшем будем маршалировать при помощи библиотеки Marshal. Так как новый интерфейс принимает указатель IntPrt, а не бинарный массив как старый.
В этой структуре у нас будет содержаться SteamID пользователя, от которого пришло сообщение, длинна сообщения и, собственно, бинарный массив с самим сообщением, в который мы будем загонять информацию путем сереализации. Для того что бы библиотека Marshal могла маршалировать нашу структуру, нам надо указать фиксированный размер нашего сообщения. А длину запоминаем что бы потом суметь его потом правильно прочитать.
public struct Package
{
public CSteamID steamIDUser;
public int messageLength;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 1024)]
public byte[] message;
}
В качесве message
наверное лучше тоже использовать результат маршалинга, но у меня вся логика была написана под бинарные массивы, да и с ними, мне кажется, работать попроще, хотя может и медленнее.
Дальше, в принципе, уже можем отправлять сообщения. Но сначала напишем метод, который будет принимать и запоминать StemID наших игроков:
public List ClientsId;
public void StartSession(List clientsId)
{
ClientsId = clientsId;
}
При первой отправке сообщения, и возможно ещё в каких то ситуациях, должно произойти рукопожатие. В документации этот момент описан так:
«Если у нас еще нет сеанса с этим пользователем, сеанс создается неявно. Возможно, должно произойти некоторое рукопожатие, прежде чем мы действительно сможем начать отправлять данные сообщений.»
Для этого создаем метод, который будет отвечать на это рукопожатие:
void SteamNetworkingMessagesSessionRequest(SteamNetworkingMessagesSessionRequest_t request)
{
CSteamID clientId = request.m_identityRemote.GetSteamID(); //Получаем SteamID того кто пытается пожать нам руку
if (ExpectingClient(clientId))
{
//Создаем сущность SteamNetworkingIdentity для подтверждения рукопожатия
var client = new SteamNetworkingIdentity();
client.SetSteamID(clientId);
SteamNetworkingMessages.AcceptSessionWithUser(ref request.m_identityRemote);
}
else
{
//Выдаем ошибку, если к нам пытается подключиться кто-то нам не знакомый
Debug.LogWarning("Unexpected session request from " + clientId);
}
}
Где ExpectingClient
это метод который вернет true
если мы готовы этому пользователю «пожать руку». В моём случае выглядит так:
bool ExpectingClient(CSteamID clientId)
{
return ClientsId.Contains(clientId);
}
Для того чтобы наш метод SteamNetworkingMessagesSessionRequest
обрабатывался, нам надо при старте создать поле обратного вызова:
private Callback _p2PSessionRequestCallback;
void Start()
{
_p2PSessionRequestCallback = Callback.Create(SteamNetworkingMessagesSessionRequest);
}
Теперь можно попыться что-то отправить:
public void SendMessage(CSteamID clientId, Package package)
{
//Создаем индетефикатор пользователя, которому хотим отправить сообщение
var client = new SteamNetworkingIdentit();
client.SetSteamID(clientId);
IntPtr _pInt_buffer = Marshal.AllocCoTaskMem(Marshal.SizeOf(package)); // выделили кусочек памяти
Marshal.StructureToPtr(package, _pInt_buffer, false); // записали содержимое
uint cubData = (uint)Marshal.SizeOf(package); //размер сообщения
int sendflag = 1; //флаг отправки
EResult result = SteamNetworkingMessages.SendMessageToUser(ref client, _pInt_buffer,cubData,sendflag,0);
// Debug.LogWarning("send message to: " + client.ToString()+"result: "+result+" size: "+cubData);
}
В этом методе cubData
это размер пересылаемого сообщения. sendflag
это флаг, используемый для отправки сообщений. Может быть следующий:
0 — отправляет сообщение ненадежно. Сообщение может быть потеряно;
1 — тоже самое что и 0, но с отключенным алгоритмом Nagle;
4 — Если сообщение не может быть отправлено очень скоро (потому что соединение все еще делает некоторые первоначальные рукопожатия, переговоры о маршруте и т. Д.), То просто отбрасывает его;
8 — Надежная отправка сообщений.
И последняя цифра, это номер канала, на котором мы передаем сообщение (в случае если не хотите использовать эту фичу, ставте 0). SendMessageToUser
возвращает результат в виде сущности EResult
, её можно вывести в дебаг.
Чтобы не заморачиваться я просто отправляю сообщения всем клиентам (разумеется кроме себя):
public void SendMessageAllClients(Package package)
{
foreach (var client in ClientsId)
{
SendMessage(client, package);
}
}
Отправить это конечно хорошо, но надо бы и что то получить. Тут немного сложнее. Для чтения используется метод:
SteamNetworkingMessages.ReceiveMessagesOnChannel(0, outMessages, readPacketCount)
В котором первая цифра, это тот самый номер канала, outMessages
это массив принятых сообщений (за раз их может несколько), а readPacketCount
это, как раз, максимальное количество сообщений, которое мы хотим прочитать.
В итоге чтение будет выглядеть примерно так:
public int readPacketCount = 10;
public List ReadMessages()
{
List packages = new List();
IntPtr[] outMessages = new IntPtr[400]; //Размер массива указал на абум, вообще надо по readPacketCount
int countMessage = SteamNetworkingMessages.ReceiveMessagesOnChannel(0, outMessages, readPacketCount);
if(countMessage>0)
{
for(int i=0;i(t);
packages.Add(package);
}
}
return packages;
}
Не забудьте, что поле message в нашей структуре фиксированного размера, а записываемая в него информация нет. Поэтому для правильной десериализации потребуется дополнительный буффер и метод Buffer.BlockCopy
. У меня это все происходит при переконвертации из структуры в класс.
На этом в принципе и всё. Далее потребуется ещё какой-нибудь класс, который будет управлять всеми этими функциями, но это тема уже отдельной статьи.