Обложка статьи «Интеграция своей игры в Steam: работа с лобби в Steamworks.NET»

Интеграция своей игры в Steam: работа с лобби в Steamworks.NET

Мало кто из геймеров не слышал про Steam. Первое появление площадки приходится аж на 2002 г. На ней крупные издатели могли безопасно распространять игры.

Спустя десяток лет появился Steam Greenlight, который дал возможность попасть на площадку не только крупным студиям, но и обычным инди-разработчикам. Пользователи сами выбирали, какие игры они хотят видеть на площадке. Но из-за появления массы второсортных игр такую систему пришлось закрыть. На место Greenlight пришел Direct. По словам разработчиков, такая система должна сделать процесс публикации упорядоченным, прозрачным и доступным для новых разработчиков со всего света.

За почти два десятилетия Steam перестал быть просто площадкой цифрового распространения. В нём появилась внутренняя экономика, достижения, коллекционные карточки, инвентарь. Всё это необходимо для повышения вовлечённости игрока. Естественно, нужно было дать возможность разработчикам как-то интегрировать эти составляющие Steam в свои игры. Для этого был создан Steamworks.

История Steam как многопользовательской платформы началась с CS 1.6. Мультиплеер всегда был одним из ключевых аспектов в процессе игры. Площадка даёт игрокам возможность связываться между собой по одноранговой сети (P2P), либо использовать выделенные игровые серверы. Для первого случая, естественно, необходим матчмейкинг — процесс объединения игроков в игровую сессию. Набор игроков происходит в лобби, где игроки могут обсудить различные игровые аспекты, выбрать персонажей и карту. Steamworks даёт исчерпывающее API для работы с матчмейкингом.

Примечание Оригинальный Steamworks работает на C++. В этой же статье будет идти речь о C# — Steamworks.NET. Это полноценная обёртка официального Steamworks. У Steam есть полная документация для матчмейкинга.

Установка (для Unity3D)

  1. Скачайте из репозитория Steamworks.NET актуальную версию SDK.
  2. Переместите всё содержимое в папку Assets.
  3. Запустите проект в Unity3D. После запуска в корне проекта создастся файл steam_appid.txt. В этом файле должен храниться ID вашего приложения в Steam. Если такового пока нет — можно использовать стандартный ID 480. Он принадлежит игре Spacewar.
  4. Перезагрузите Unity3D, чтобы изменения файла вошли в силу.
  5. Обратите внимание на наличие файла SteamManager.cs. Он выполняет несколько крайне важных функций. Если файла нет, то его всегда можно найти в репозитории.

Введение

Ключевую роль в Steamworks играют Callback и CallResult. Обратные вызовы позволяют игре асинхронно работать со Steam.

Callback вызывается при каких-либо событиях в Steam. Это могут быть события получения сообщения в чате, изменение списка игроков лобби или даже открытие игрового оверлея. Рассмотрим следующий код, взятый с вики Steamworks.NET:

public class SteamScript : MonoBehaviour {
	Callback m_GameOverlayActivated;

	private void OnEnable() {
		if (SteamManager.Initialized)
			m_GameOverlayActivated = Callback.Create(OnGameOverlayActivated);
	}

	private void OnGameOverlayActivated(GameOverlayActivated_t pCallback) {
		if(pCallback.m_bActive != 0)
			Debug.Log("Оверлей Steam открыт");
		else 
			Debug.Log("Оверлей Steam закрыт");
	}
}

Вначале нужно создать экземпляр Callback. В данном случае это событие открытия/закрытия оверлея. Callback нужно инициализировать, привязав к нему функцию. Это стоит делать только убедившись, что Steam уже инициализирован: SteamManager.Initialized. Для этого лучше всего подойдёт метод OnEnable(), который вызывается сразу при старте игры.

Функции будет передана переменная, содержащая результат события. Для каждого типа обратного вызова свой тип переменной.

CallResult очень похож на Callback. Разница в том, что CallResult является результатом вызова определённого метода. Это может быть, к примеру, результат создания лобби или подключения к нему. Рассмотрим код с вики:

public class SteamScript : MonoBehaviour {
	private CallResult m_NumberOfCurrentPlayers;

	private void OnEnable() {
		if (SteamManager.Initialized)
			m_NumberOfCurrentPlayers = CallResult.Create(OnNumberOfCurrentPlayers);
	}

	private void Update() {
		if(Input.GetKeyDown(KeyCode.Space)) {
			SteamAPICall_t handle = SteamUserStats.GetNumberOfCurrentPlayers();
			m_NumberOfCurrentPlayers.Set(handle);
			Debug.Log("Вызван метод GetNumberOfCurrentPlayers()");
		}
	}

	private void OnNumberOfCurrentPlayers(NumberOfCurrentPlayers_t pCallback, bool bIOFailure)
		if (pCallback.m_bSuccess != 1 || bIOFailure) {
			Debug.Log("Возникла ошибка при обработке  NumberOfCurrentPlayers.");
		else
			Debug.Log("Количество игроков в игре: " + pCallback.m_cPlayers);
	}
}

Как и в случае с Callback, тут сначала нужно создать экземпляр CallResult и инициализировать его. В методе Update() идёт проверка на нажатие пробела. По нажатию будет отправлен запрос на получение количества игроков. Как и в прошлом случае, после получения ответа вызывается указанный метод, которому будет передан результат.

Обратите внимание, что при работе с CallResult в сигнатуре метода всегда будет bool bIOFailure.

Для работы Callback и CallResult нужно циклически вызывать метод SteamAPI.RunCallbacks().

Примечание Вызов этого метода уже реализован в SteamManager.cs.

Подготовка

Для работы с матчмейкингом вам понадобятся некоторые структуры:

struct LobbyMetaData
{
    public string m_Key;
    public string m_Value;
}

struct LobbyMembers
{
    public CSteamID m_SteamID;
    public LobbyMetaData[] m_Data;
}

struct Lobby
{
    public CSteamID m_SteamID;
    public CSteamID m_Owner;
    public LobbyMembers[] m_Members;
    public int m_MemberLimit;
    public LobbyMetaData[] m_Data;
}

Каждое лобби имеет свои мета-данные: название карты или же режим игры. Нет каких-либо шаблонных данных — всё остаётся за разработчиком. Для работы с мета-данными понадобится структура LobbyMetaData. Она представляет собой стандартную пару ключ-значение.

В лобби каждый игрок представляет из себя структуру LobbyMembers, главным свойством которой является m_SteamID — уникальный ID пользователя Steam.

Структура Lobby описывает непосредственно лобби, а точнее — самые необходимые свойства, такие как:

  • уникальный ID лобби;
  • ID владельца лобби;
  • список игроков в лобби;
  • максимальное количество игроков в лобби;
  • мета-данные лобби.

Также потребуются некоторые экземпляры Callbacks и CallResult, а именно:

CallResult m_LobbyEnterCallResult; // При входе в лобби
CallResult m_LobbyMatchListCallResult; // При получении списка лобби
CallResult m_LobbyCreatedCallResult; // При создании лобби
Callback m_LobbyChatMsgCallResult; // При получении сообщения в лобби
Callback m_LobbyChatUpdateCallResult; // При изменении списка игроков в лобби (когда какой-либо игрок входит в лобби или выходит)
Callback m_LobbyDataUpdateCallResult; // При обновлении мета-данных лобби

И как полагается, стоит инициализировать все обратные вызовы и создать для них соответствующие методы:

void OnEnable()
{
    if (!SteamManager.Initialized)
        return;

    m_LobbyEnterCallResult = CallResult.Create(OnLobbyEnter);
    m_LobbyMatchListCallResult = CallResult.Create(OnLobbyMatchList);
    m_LobbyCreatedCallResult = CallResult.Create(OnLobbyCreated);
    m_LobbyChatMsgCallResult = Callback.Create(OnLobbyChatMsg);
    m_LobbyChatUpdateCallResult = Callback.Create(OnLobbyChatUpdate);      
    m_LobbyDataUpdateCallResult = Callback.Create(OnLobbyDataUpdate);
}

void OnLobbyEnter(LobbyEnter_t pCallback, bool bIOFailure)
{
    // При входе в лобби...
}

void OnLobbyMatchList(LobbyMatchList_t pCallback, bool bIOFailure)
{
  // При получении списка лобби...
}

void OnLobbyCreated(LobbyCreated_t pCallback, bool bIOFailure)
{
    // При создании лобби...
}

void OnLobbyChatMsg(LobbyChatMsg_t pCallback)
{
    // При получении сообщения в лобби...
}

void OnLobbyChatUpdate(LobbyChatUpdate_t pCallback)
{
    // При изменении списка игроков в лобби...
}

void OnLobbyDataUpdate(LobbyDataUpdate_t pCallback)
{
    // При обновлении мета-данных лобби...
}

Получение списка лобби

Чтобы получить список существующих лобби, используйте:

m_LobbyMatchListCallResult.Set(SteamMatchmaking.RequestLobbyList());

После получения ответа вызовется метод OnLobbyMatchList. Методу передаётся только одно число — количество лобби. Его можно взять из переменной pCallback.m_nLobbiesMatching.

Внимание Steamworks может вернуть в списке не более 50 лобби.

После получения списка лобби их неплохо было бы отобразить. Перебор списка лобби будет выглядеть так:

for (int i = 0; i < pCallback.m_nLobbiesMatching; i++)       
    RenderLobby(SteamMatchmaking.GetLobbyByIndex(i));

Вам нужно будет создать какой-нибудь метод отображения списка лобби (RenderLobby), который будет принимать ID лобби:

Lobby lobby = new Lobby();
lobby.m_SteamID = steamIDLobby; // ID, который передавался методу
lobby.m_Owner = SteamMatchmaking.GetLobbyOwner(steamIDLobby);
lobby.m_Members = new LobbyMembers[SteamMatchmaking.GetNumLobbyMembers(steamIDLobby)];
lobby.m_MemberLimit = SteamMatchmaking.GetLobbyMemberLimit(steamIDLobby);     

int DataCount = SteamMatchmaking.GetLobbyDataCount(steamIDLobby);

lobby.m_Data = new LobbyMetaData[DataCount];
for (int i = 0; i < DataCount; i++) // Получение всех мета-данных лобби
{
    bool lobbyDataRet = SteamMatchmaking.GetLobbyDataByIndex(steamIDLobby, i, out lobby.m_Data[i].m_Key, 
        Constants.k_nMaxLobbyKeyLength, out lobby.m_Data[i].m_Value, Constants.k_cubChatMetadataMax);
            
    if (!lobbyDataRet){
        Debug.LogError("Ошибка при получении мета-данных лобби");
        continue;
    }
}
//
// Отображение лобби в списке...
//

Потом нужно дать возможность пользователю выбрать лобби, к которому он захочет подключиться, либо создать своё.

Фильтр списка лобби

Steamworks даёт возможность отфильтровать возвращаемый список по некоторым категориям.

Внимание Фильтр нужно устанавливать перед вызовом RequestLobbyList().

Вначале можно указать максимальное количество возвращаемых лобби. Чем меньше количество — тем быстрее обработается результат. Сделать это можно функцией SteamMatchmaking.AddRequestLobbyListResultCountFilter(max_count);.

Дальше есть несколько типов фильтров (все они находятся в классе SteamMatchmaking):

  • AddRequestLobbyListDistanceFilter — задаёт расстояние, в пределах которого нужно искать лобби (исходя из IP пользователя). Принимает ELobbyDistanceFilter.
  • AddRequestLobbyListFilterSlotsAvailable — оставляет только те лобби, в которых доступно указанное количество свободных слотов.
  • AddRequestLobbyListNearValueFilter — сортирует лобби по степени удалённости значения от указанного. Таких фильтров можно указать несколько. Первый будет иметь больше всего влияния на сортировку, последний — меньше всего.
  • AddRequestLobbyListNumericalFilter — задаёт числовой тип сравнения.
  • AddRequestLobbyListStringFilter — задаёт строковый тип сравнения.

Три последних фильтра сравнивают/сортируют лобби по их мета-данным.

Например, после создания лобби вы выбираете какую-либо локацию. Название локации нужно будет сохранить в мета-данных лобби под ключом map_name. Остальные пользователи смогут отфильтровать список лобби, в которых значение мета-данных map_name будет тем, которые они выставят. Таким образом игрок сможет найти лобби с той локацией, которую он захочет.

Подключение к лобби

Чтобы присоединиться к существующему лобби:

m_LobbyEnterCallResult.Set(SteamMatchmaking.JoinLobby(LobbyID));

После удачного подключения к лобби будет вызван метод OnLobbyEnter. У всех же остальных участников лобби будет вызван метод OnLobbyChatUpdate.

Создание своего лобби

Чтобы создать собственное лобби, используйте метод:

m_LobbyCreatedCallResult.Set(SteamMatchmaking.CreateLobby(ELobbyType.k_ELobbyTypePublic, 4));

Метод CreateLobby принимает два параметра. Первый — тип видимости лобби (по приглашению/для друзей/открытое); второй — максимальное количество игроков. В лобби может быть до 250 игроков, хотя на практике — от 2 до 5.

После удачного создания лобби будет вызван метод OnLobbyCreated.

Пребывание в лобби

Скорей всего, в лобби вам нужно будет отображать список игроков и чат. Для этого потребуются некоторые методы. К примеру, чтобы получить Sprite, содержащий аватар пользователя, используйте метод:

public static Sprite GetUserAvatar(CSteamID ID)
{
    Texture2D original = null;
    uint width, height;

    int image = SteamFriends.GetLargeFriendAvatar(ID); 

    bool IsValid = SteamUtils.GetImageSize(image, out width, out height);

    if (IsValid)
    {
        byte[] data = new byte[width * height * 4];

        IsValid = SteamUtils.GetImageRGBA(image, data, (int)(width * height * 4));
        if (IsValid)
        {
            original = new Texture2D((int)width, (int)height, TextureFormat.RGBA32, false, true);
            original.LoadRawTextureData(data);
            original.Apply();
        }
    }

    Texture2D flipped = new Texture2D((int)width, (int)height);

    int x = (int) width, y = (int) height;

    for (int i = 0; i < x; i++)
        for (int j = 0; j < y; j++)
            flipped.SetPixel(j, x - i - 1, original.GetPixel(j, i));

    flipped.Apply();

    return Sprite.Create(flipped, new Rect(0f, 0f, original.width, original.height), Vector2.zero);
}

При этом разрешение аватара будет 128×128 пикселей.

Чтобы получить собственный Steam ID используйте SteamUser.GetSteamID(). Для получения своего имени — SteamFriends.GetPersonaName(). Если нужно получить имя другого пользователя — SteamFriends.GetFriendPersonaName(PlayerID).

Отправка сообщений в лобби

Steamworks даёт возможность обмена информацией в лобби. Это может быть уведомление о готовности какого-нибудь игрока, смена персонажа либо банальное получение сообщений в чате. В любом случае вам понадобится следующий метод:

void SendData(string data)
{
    byte[] bytes = System.Text.Encoding.Default.GetBytes(data);
    SteamMatchmaking.SendLobbyChatMsg(current_lobby_id, bytes, bytes.Length + 1);
}

Обратите внимание, что переменную current_lobby_id вы должны обновлять самостоятельно при создании собственного лобби либо подключении к существующему.

После получения сообщения у всех пользователей (в том числе и у отправителя) вызовется метод OnLobbyChatMsg.

Неплохим решением будет создание объекта данных, который будет иметь определённый тип (сообщение в чате, изменение готовности игрока и т. д.). Для отправки данных нужно будет его сериализовать в строку (например JSON) и отправить всем остальным через SendData. При получении такого сообщения нужно будет десериализовать сообщение в объект, определить его тип и обработать. Тогда OnLobbyChatMsg будет начинаться примерно так:

void OnLobbyChatMsg(LobbyChatMsg_t pCallback)
{
    CSteamID SteamIDUser; // ID отправителя

    byte[] Data = new byte[4096]; // Максимальный размер сообщения
    EChatEntryType ChatEntryType;
    int ret = SteamMatchmaking.GetLobbyChatEntry((CSteamID)pCallback.m_ulSteamIDLobby, (int)pCallback.m_iChatID, out SteamIDUser, Data, Data.Length, out ChatEntryType);

    string data = System.Text.Encoding.Default.GetString(Data);
    //
    // Дальнейшая обработка data...
    //
}

Изменение мета-данных лобби

Как говорилось ранее, мета-данные нужны для хранения какой-либо игровой информации о лобби: название карты, режим игры, минимальный уровень и т. д. Изменять мета-данные может только владелец лобби. Для быстрой проверки владения лобби можно использовать такой метод:

public bool IsLobbyOwner() => SteamUser.GetSteamID() == current_lobby_owner;

Для создания или изменения мета-данных используется этот метод:

SteamMatchmaking.SetLobbyData(current_lobby_id, key, value);

Как понятно из кода, метод SetLobbyData работает по стандартной схеме ключ-значение. Для всех остальных участников лобби есть отдельный аналогичный метод SetLobbyMemberData.

Как только данные будут отосланы у всех клиентов вызовется метод OnLobbyDataUpdate. Новые игроки, которые только вошли в лобби, будут получать сразу новые значения мета-данных.

Примечание. Перед отправкой данных происходит небольшая задержка. Несколько изменённых подряд мета-данных будут объединены и отправлены одним пакетом.

Чтобы удалить мета-данные, используйте:

SteamMatchmaking.DeleteLobbyData(current_lobby_id, key);

Не смешно? А здесь смешно: @ithumor