Найти тему
Habr.com

Кооператив на Unity за «Бесплатно», или p2p соединение через ISteamNetworkingMessages

Разрабатывая вторую игру на Unity я решил замахнуться на кооперативный режим. Так как новая игра тоже выйдет на площадке Steam, сервисы стима уже интегрированны, а взнос за приложение уже уплачен, было решено попробовать сетевые сервисы стима. Steam заявляет что они очень круто работают, сервера расположены по всему миру (спойлер, это не так), работать с ними просто, а главное работает всё быстро.

Так как использовать сервер я не хочу, мне больше всего подходит вариант p2p соединения, и у Steam такое есть (даже два).

Как я уже сказал, у Steam есть два сетевых интерфейса ориентированных на p2p. Первый называется ISteamNetworking, в документации стим пишет что он устарел, и его уже даже удалять хотят. Я разумеется этой строки не заметил, и сначала написал все на этом интерфейсе. Кстати, про него я нашел пару англоязычных статей.

Для работы понадобится какая-то структура, которая будет содержать передаваемую информацию. У меня используется класс с названием 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<CSteamID> ClientsId; public void StartSession(List<CSteamID> 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<SteamNetworkingMessagesSessionRequest_t> _p2PSessionRequestCallback; void Start() { _p2PSessionRequestCallback = Callback<SteamNetworkingMessagesSessionRequest_t>.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<Package> ReadMessages() { List<Package> packages = new List<Package>(); IntPtr[] outMessages = new IntPtr[400]; //Размер массива указал на абум, вообще надо по readPacketCount int countMessage = SteamNetworkingMessages.ReceiveMessagesOnChannel(0, outMessages, readPacketCount); if(countMessage>0) { for(int i=0;i<countMessage;i++) { var message = outMessages[i]; // выбираем указатель на очередное сообщение //читаем сообщение из памяти и преобразуем в структуру var t = Marshal.ReadIntPtr(message); var paccageStr = Marshal.PtrToStructure<Package.PaccageStruct>(t); packages.Add(package); } } return packages; }

Не забудьте, что поле message в нашей структуре фиксированного размера, а записываемая в него информация нет. Поэтому для правильной десериализации потребуется дополнительный буффер и метод Buffer.BlockCopy. У меня это все происходит при переконвертации из структуры в класс.

На этом в принципе и всё. Далее потребуется ещё какой-нибудь класс, который будет управлять всеми этими функциями, но это тема уже отдельной статьи.