Разрабатывая вторую игру на 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<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. У меня это все происходит при переконвертации из структуры в класс.

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

Комментарии (3)


  1. IgorIngeneer
    26.12.2021 17:18
    +1

    а какова задержка от времени отправки до времени получения?


    1. EnvalidGamer Автор
      26.12.2021 17:32

      У меня выходит от 100 до 600 мс, но я живу в Сибири, а все мои пакеты почему-то летят через Стокгольм :D (там ближайший сервер). В принципе, для моей неторопливой игрушки достаточно.

      С этим вопросом как раз сейчас разбираюсь. По идее при p2p связи сервер в Стокгольме нужен только на момент соединения (рукопожатия).


      1. IgorIngeneer
        26.12.2021 21:32

        я тетстировал WebRTC и у меня получалось порядка 50 мс...