유니티 게이밍 서비스 로비 마스터하기 / Master Unity Gaming Services Lobby UGS
유니티 게이밍 서비스 로비(UGS Lobby)란?
유니티 게이밍 서비스 중 플레이어를 관리하고 매칭시켜주는 로비 서버입니다.
유니티의 서버를 사용하기에 월 무료구간 이후 과금제로 운영됩니다.
https://unity.com/kr/products/gaming-services/pricing
에서 가격을 확인할 수 있습니다.
https://docs.unity.com/ugs/ko-kr/manual/lobby/manual/unity-lobby-service
에서 공식문서를 확인할 수 있습니다.
로비의 특징으로는
- 로비의 생성, 참여, 수정, 삭제를 간편하게 할 수 있습니다.
- 로비 목록을 필터링을 걸어 확인할 수 있습니다.
- 로비와 플레이어의 데이터를 편집할 수 있습니다.
- 비밀번호로 참여, 참여코드로 참여, 빠른 참여가 가능합니다.
설치
Window - Package Manager - Multiplayer Services를 설치합니다.
구 버전에는 Lobby를 따로 설치했으나 인증, 로비, 릴레이 등 UGS를 통합한 멀티 서비스입니다.
Edit - Project Settings... - Services에서 유니티 클라우드와 프로젝트를 연결합니다.
연결이 되었으면 Dashboard를 눌러 유니티 클라우드로 이동합니다.
제품에 Lobby를 검색해 실행합니다.
관리 - 서비스 사용량에서 사용량을 알 수 있습니다.
로비 매니저 (LobbyManager)
사용하기 편리하도록 로비 관련 함수들을 담은 래핑 클래스로 만들었습니다.
LobbyManager.cs
using System;
using UnityEngine;
using System.Collections.Generic;
using System.Collections.Concurrent;
using Unity.Services.Authentication;
using Unity.Services.Core;
using Unity.Services.Lobbies;
using Unity.Services.Lobbies.Models;
namespace Unity.Services.Lobbies.Models
{
// User Custom enums
public enum EPlayerOptionKey { PlayerName }
public enum ELobbyOptionKey { GameMode, Map }
}
public class LobbyManager : MonoBehaviour
{
public string CurPlayerId { get => curPlayerId; }
public Lobby CurLobby { get => curLobby; }
string curPlayerId;
Lobby curLobby;
Player optionPlayer;
float heartbeatTimer;
float heartbeatTimerMax = 15;
ConcurrentQueue<string> createdLobbyIds = new();
LobbyEventCallbacks lobbyEvents = new();
public async Awaitable<(bool success, string playerId)> SignIn()
{
try
{
await UnityServices.InitializeAsync();
if (!AuthenticationService.Instance.IsSignedIn)
{
await AuthenticationService.Instance.SignInAnonymouslyAsync();
}
curPlayerId = AuthenticationService.Instance.PlayerId;
return (true, curPlayerId);
}
catch (Exception e)
{
Debug.Log($"SignIn failed. {e}");
return (false, string.Empty);
}
}
/// <summary> Sign in and initialize required. </summary>
public void SetOptionCurPlayer(Dictionary<EPlayerOptionKey, string> playerDic)
{
Dictionary<string, PlayerDataObject> optionPlayerDic = new();
foreach (var (key, value) in playerDic)
{
optionPlayerDic[key.ToString()] = new PlayerDataObject(PlayerDataObject.VisibilityOptions.Member, value);
}
optionPlayer = new Player(id: curPlayerId, data: optionPlayerDic);
}
// <summary> isPublic is true, password is 8 ~ 64 characters. </summary>
public async Awaitable<(bool success, Lobby lobby)> CreateLobby(string lobbyName,
int maxPlayers, bool isPublic, string password, Dictionary<ELobbyOptionKey, string> dataDic)
{
try
{
CreateLobbyOptions options = new()
{
IsPrivate = !isPublic,
Player = optionPlayer,
Data = GetLobbyOptionDic(dataDic)
};
if (!isPublic && !string.IsNullOrEmpty(password) && password.Length >= 8)
{
options.Password = password;
}
curLobby = await LobbyService.Instance.CreateLobbyAsync(lobbyName, maxPlayers, options);
await SubscribeLobbyEvent(curLobby.Id);
createdLobbyIds.Enqueue(curLobby.Id);
return (true, curLobby);
}
catch (Exception e)
{
Debug.Log($"CreateLobby failed. {e}");
return (false, null);
}
}
public async Awaitable<(bool success, Lobby lobby)> JoinLobbyById(string lobbyId, string password)
{
try
{
JoinLobbyByIdOptions options = new()
{
Player = optionPlayer,
};
if (!string.IsNullOrEmpty(password) && password.Length >= 8)
{
options.Password = password;
}
curLobby = await LobbyService.Instance.JoinLobbyByIdAsync(lobbyId, options);
await SubscribeLobbyEvent(curLobby.Id);
return (true, curLobby);
}
catch (Exception e)
{
Debug.Log($"JoinLobbyById failed. {e}");
return (false, null);
}
}
public async Awaitable<(bool success, Lobby lobby)> JoinLobbyByCode(string lobbyCode, string password)
{
try
{
JoinLobbyByCodeOptions options = new()
{
Player = optionPlayer
};
if (!string.IsNullOrEmpty(password) && password.Length >= 8)
{
options.Password = password;
}
curLobby = await LobbyService.Instance.JoinLobbyByCodeAsync(lobbyCode, options);
await SubscribeLobbyEvent(curLobby.Id);
return (true, curLobby);
}
catch (Exception e)
{
Debug.Log($"JoinLobbyByCode failed. {e}");
return (false, null);
}
}
public async Awaitable<(bool success, Lobby lobby)> QuickJoinLobby(params string[] filters)
{
try
{
QuickJoinLobbyOptions options = new()
{
Player = optionPlayer,
Filter = GetQueryFilters(filters)
};
curLobby = await LobbyService.Instance.QuickJoinLobbyAsync(options);
await SubscribeLobbyEvent(curLobby.Id);
return (true, curLobby);
}
catch (Exception e)
{
Debug.Log($"QuickJoinLobby failed. {e}");
return (false, null);
}
}
public async Awaitable<(bool success, List<Lobby> lobbies)> GetListLobbies(int skip, int count, params string[] filters)
{
try
{
QueryLobbiesOptions options = new()
{
Skip = skip,
Count = count,
Filters = GetQueryFilters(filters),
Order = new List<QueryOrder> { new QueryOrder(false, QueryOrder.FieldOptions.Created) }
};
QueryResponse queryResponse = await LobbyService.Instance.QueryLobbiesAsync(options);
List<Lobby> lobbies = queryResponse.Results;
return (true, lobbies);
}
catch (Exception e)
{
Debug.Log($"GetListLobbies failed. {e}");
return (false, null);
}
}
public async Awaitable<(bool success, Lobby lobby)> UpdateLobby(string lobbyName,
int maxPlayers, bool isPublic, string password, Dictionary<ELobbyOptionKey, string> dataDic)
{
try
{
UpdateLobbyOptions options = new()
{
Name = lobbyName,
MaxPlayers = maxPlayers,
IsPrivate = !isPublic,
Data = GetLobbyOptionDic(dataDic)
};
if (!isPublic && !string.IsNullOrEmpty(password) && password.Length >= 8)
{
options.Password = password;
}
curLobby = await LobbyService.Instance.UpdateLobbyAsync(curLobby.Id, options);
return (true, curLobby);
}
catch (Exception e)
{
Debug.Log($"UpdateLobby failed. {e}");
return (false, null);
}
}
public async Awaitable<(bool success, Lobby lobby)> UpdatePlayer(string playerId,
Dictionary<EPlayerOptionKey, string> playerDic)
{
try
{
UpdatePlayerOptions options = new()
{
Data = GetPlayerOptionDic(playerDic)
};
SetOptionCurPlayer(playerDic);
curLobby = await LobbyService.Instance.UpdatePlayerAsync(curLobby.Id, playerId, options);
return (true, curLobby);
}
catch (Exception e)
{
Debug.Log($"UpdatePlayer failed. {e}");
return (false, null);
}
}
public async Awaitable<bool> DeleteLobby(string lobbyId)
{
try
{
await LobbyService.Instance.DeleteLobbyAsync(lobbyId);
curLobby = null;
lobbyEvents = new LobbyEventCallbacks();
return true;
}
catch (Exception e)
{
Debug.Log($"DeleteLobby failed. {e}");
return false;
}
}
public async Awaitable<bool> KickPlayer(string playerId)
{
try
{
await LobbyService.Instance.RemovePlayerAsync(curLobby.Id, playerId);
return true;
}
catch (Exception e)
{
Debug.Log($"KickPlayer failed. {e}");
return false;
}
}
public async Awaitable<(bool success, Lobby lobby)> GetLobby(string lobbyId)
{
try
{
Lobby lobby = await LobbyService.Instance.GetLobbyAsync(lobbyId);
return (true, lobby);
}
catch (Exception e)
{
Debug.Log($"GetLobby failed. {e}");
return (false, null);
}
}
public async Awaitable<(bool success, List<string> lobbyIds)> GetJoinedLobbyIds()
{
try
{
List<string> lobbyIds = await LobbyService.Instance.GetJoinedLobbiesAsync();
return (true, lobbyIds);
}
catch (Exception e)
{
Debug.Log($"GetJoinedLobbyIds failed. {e}");
return (false, null);
}
}
public async Awaitable<bool> ReconnectLobby()
{
try
{
await LobbyService.Instance.ReconnectToLobbyAsync(curLobby.Id);
return true;
}
catch (Exception e)
{
Debug.Log($"ReconnectLobby failed. {e}");
return false;
}
}
public void PrintLobbyInfo()
{
if (curLobby == null)
{
Debug.Log($"curLobby is null");
return;
}
Debug.Log("------Lobby detault------");
Debug.Log($"LobbyId: {curLobby.Id}");
Debug.Log($"LobbyName: {curLobby.Name}");
Debug.Log($"LobbyCode: {curLobby.LobbyCode}");
Debug.Log($"LobbyPlayerCount: {curLobby.Players.Count}");
Debug.Log($"LobbyMaxPlayers: {curLobby.MaxPlayers}");
Debug.Log($"LobbyIsPrivate: {curLobby.IsPrivate}");
Debug.Log($"LobbyHasPassword: {curLobby.HasPassword}");
Debug.Log($"LobbyIsLocked: {curLobby.IsLocked}");
Debug.Log($"LobbyHostId: {curLobby.HostId}");
Debug.Log("------Lobby data------");
foreach (var (key, dataObject) in curLobby.Data)
{
Debug.Log($"{key}: {dataObject.Value}");
}
Debug.Log("------Lobby player data------");
foreach (Player player in curLobby.Players)
{
Debug.Log($"PlayerId: {player.Id}");
foreach (var (key, playerDataObject) in player.Data)
{
Debug.Log($"{key}: {playerDataObject.Value}");
}
}
}
Dictionary<string, DataObject> GetLobbyOptionDic(Dictionary<ELobbyOptionKey, string> dataDic)
{
Dictionary<string, DataObject> resultDic = new();
foreach (var (key, value) in dataDic)
{
resultDic[key.ToString()] = new DataObject(DataObject.VisibilityOptions.Public, value);
}
return resultDic;
}
Dictionary<string, PlayerDataObject> GetPlayerOptionDic(Dictionary<EPlayerOptionKey, string> playerDic)
{
Dictionary<string, PlayerDataObject> resultDic = new();
foreach (var (key, value) in playerDic)
{
resultDic[key.ToString()] = new PlayerDataObject(PlayerDataObject.VisibilityOptions.Public, value);
}
return resultDic;
}
List<QueryFilter> GetQueryFilters(params string[] filters)
{
List<QueryFilter> resultFilters = new()
{
new QueryFilter(QueryFilter.FieldOptions.AvailableSlots, "0", QueryFilter.OpOptions.GT)
};
if (filters != null && filters.Length > 0)
{
for (int i = 0; i < filters.Length; i++)
{
resultFilters.Add(new QueryFilter(QueryFilter.FieldOptions.S1, filters[i], QueryFilter.OpOptions.EQ));
}
}
return resultFilters;
}
async void HandleLobbyHeartbeat()
{
if (curLobby != null && curLobby.HostId == curPlayerId)
{
heartbeatTimer -= Time.deltaTime;
if (heartbeatTimer < 0)
{
heartbeatTimer = heartbeatTimerMax;
await LobbyService.Instance.SendHeartbeatPingAsync(curLobby.Id);
}
}
}
async void HandleCreatedLobbyDelete()
{
while (createdLobbyIds.TryDequeue(out string lobbyId))
{
await DeleteLobby(lobbyId);
}
}
void OnKickedFromLobby()
{
Debug.Log($"Kicked from lobby");
curLobby = null;
lobbyEvents = new LobbyEventCallbacks();
}
void OnLobbyChanged(ILobbyChanges changes)
{
if (changes.LobbyDeleted)
{
Debug.Log($"Lobby deleted");
curLobby = null;
lobbyEvents = new LobbyEventCallbacks();
}
else
{
Debug.Log($"Lobby changed");
changes.ApplyToLobby(curLobby);
}
}
void OnLobbyEventConnectionStateChanged(LobbyEventConnectionState state)
{
Debug.Log($"LobbyEventConnectionStateChanged: {state}");
}
async Awaitable<bool> SubscribeLobbyEvent(string lobbyId)
{
try
{
lobbyEvents.LobbyChanged += OnLobbyChanged;
lobbyEvents.KickedFromLobby += OnKickedFromLobby;
lobbyEvents.LobbyEventConnectionStateChanged += OnLobbyEventConnectionStateChanged;
await LobbyService.Instance.SubscribeToLobbyEventsAsync(lobbyId, lobbyEvents);
Debug.Log($"SubscribeLobbyEvent success");
return true;
}
catch (Exception e)
{
Debug.Log($"SubscribeLobbyEvent failed. {e}");
return false;
}
}
void Update()
{
HandleLobbyHeartbeat();
}
void OnApplicationQuit()
{
lobbyEvents = null;
HandleCreatedLobbyDelete();
}
}
맨 위에 User Custom enums가 있는데 이는 사용자가 플레이어와 로비에 커스텀 데이터를 정하는 키 값입니다.
편집해 쓰시면 됩니다.
EPlayerOptionKey로는 PlayerName이 있어 플레이어의 이름을 담습니다.
ELobbyOptionKey로는 GameMode (Easy, Normal, Hard), Map (Pasture, Desert) 같이
여러분이 키와 값을 설계하는 부분입니다.
프로퍼티
- string CurPlayerId: 로그인 된 아이디입니다.
- Lobby CurLobby: 참여하고 있는 로비입니다.
함수
- Awaitable<(bool success, string playerId)> SignIn()
다른 로그인 인증을 사용해도 되지만 간단히 익명로그인으로 로그인을 합니다. - void SetOptionCurPlayer(Dictionary<EPlayerOptionKey, string> playerDic)
로그인 후 바로 호출해야 하며 플레이어 데이터 커스텀 딕셔너리로 아이디와 함께 플레이어를 초기화합니다. - Awaitable<(bool success, Lobby lobby)> CreateLobby(string lobbyName,
int maxPlayers, bool isPublic, string password, Dictionary<ELobbyOptionKey, string> dataDic)
로비를 생성합니다.
로비이름, 최대 플레이어 수, 보이는지 (false라면 로비 리스트에 보이지 않습니다),
비밀번호 (8~64의 문자), 로비 데이터 커스텀 딕셔너리 매개변수에 따라 생성합니다.
비밀로비를 만들때는 비밀번호가 8자 미만이면 비밀번호가 안들어갑니다. - Awaitable<(bool success, Lobby lobby)> JoinLobbyById(string lobbyId, string password)
로비 아이디, (비밀번호가 있는경우 비밀번호도 포함)로 참여합니다. - Awaitable<(bool success, Lobby lobby)> JoinLobbyByCode(string lobbyCode, string password)
로비 코드 (짧음), (비밀번호가 있는경우 비밀번호도 포함)로 참여합니다. - Awaitable<(bool success, Lobby lobby)> QuickJoinLobby(params string[] filters)
로비 생성 순으로 (필터가 있는경우 필터 포함) 빠른 참여를 합니다. - Awaitable<(bool success, List<Lobby> lobbies)> GetListLobbies(int skip, int count, params string[] filters)
skip 인덱스부터 count개수만큼 (필터가 있는경우 필터 포함)하여 로비리스트를 가져옵니다.
로비에 이미 들어와 있는 경우에는 리스트를 볼 수 없습니다. - Awaitable<(bool success, Lobby lobby)> UpdateLobby(string lobbyName,
int maxPlayers, bool isPublic, string password, Dictionary<ELobbyOptionKey, string> dataDic)
로비 호스트는 로비의 설정을 변경할 수 있습니다.
로비 이름, 최대 플레이어 수, 보이는지, 비밀번호, 로비 데이터 커스텀 딕셔너리를 바꿉니다. - Awaitable<(bool success, Lobby lobby)> UpdatePlayer(string playerId,
Dictionary<EPlayerOptionKey, string> playerDic)
플레이어 데이터 커스텀 딕셔너리를 바꿉니다. - Awaitable<bool> DeleteLobby(string lobbyId)
로비 호스트는 로비를 제거하고 자신을 포함 모두 로비에서 탈퇴시킵니다. - Awaitable<bool> KickPlayer(string playerId)
로비 호스트는 플레이어 아이디로 플레이어를 탈퇴시킵니다.
자신을 선택하면 다른 사람이 호스트가 되며 자신은 탈퇴됩니다. - Awaitable<(bool success, Lobby lobby)> GetLobby(string lobbyId)
로비 아이디로 로비를 얻어 올 수 있습니다. - Awaitable<(bool success, List<string> lobbyIds)> GetJoinedLobbyIds()
참여하고 있는 로비 아이디들을 얻어옵니다. 기본적으로 하나입니다. - Awaitable<bool> ReconnectLobby()
연결이 잠깐 끊어진 경우 마지막 접속한 로비로 재연결을 시도합니다. - void PrintLobbyInfo()
현재 로비의 자세한 정보와 로비 커스텀 데이터, 플레이어 커스텀 데이터들을 한눈에 봅니다.
Test.cs
using System.Collections.Generic;
using UnityEngine;
using Unity.Services.Lobbies.Models;
using QuickCmd;
public class Test : MonoBehaviour
{
[SerializeField] LobbyManager lobbyManager;
[Command]
async void SignIn(string playerName)
{
(bool success, string playerId) = await lobbyManager.SignIn();
if (success)
{
Dictionary<EPlayerOptionKey, string> playerDic = new()
{
{ EPlayerOptionKey.PlayerName, playerName },
};
lobbyManager.SetOptionCurPlayer(playerDic);
Debug.Log($"SignIn success! PlayerId:{playerId}, PlayerName:{playerName}");
}
}
[Command]
async void CreateLobby(string lobbyName, int maxPlayers, bool isPublic, string password, string gameMode, string map)
{
Dictionary<ELobbyOptionKey, string> dataDic = new()
{
{ ELobbyOptionKey.GameMode, gameMode },
{ ELobbyOptionKey.Map, map },
};
(bool success, Lobby lobby) = await lobbyManager.CreateLobby(lobbyName, maxPlayers, isPublic, password, dataDic);
if (success)
{
Debug.Log($"Lobby created! Name:{lobby.Name}, LobbyCode:{lobby.LobbyCode}");
}
}
[Command]
void FastCreatePublicLobby()
{
CreateLobby("TestLobby", 4, true, string.Empty, "Easy", "Pasture");
}
[Command]
async void JoinLobbyById(string lobbyId, string password)
{
(bool success, Lobby lobby) = await lobbyManager.JoinLobbyById(lobbyId, password);
if (success)
{
Debug.Log($"Lobby joined! Name:{lobby.Name}");
}
}
[Command]
async void JoinLobbyByCode(string lobbyCode, string password)
{
(bool success, Lobby lobby) = await lobbyManager.JoinLobbyByCode(lobbyCode, password);
if (success)
{
Debug.Log($"Lobby joined! Name:{lobby.Name}");
}
}
[Command]
async void QuickJoinLobby()
{
(bool success, Lobby lobby) = await lobbyManager.QuickJoinLobby();
if (success)
{
Debug.Log($"Lobby joined! Name:{lobby.Name}");
}
}
[Command]
async void GetListLobbies(int skip, int count)
{
(bool success, List<Lobby> lobbies) = await lobbyManager.GetListLobbies(skip, count);
if (success)
{
Debug.Log($"Lobbies:{lobbies.Count}");
}
}
[Command]
async void UpdateLobby(string lobbyName, int maxPlayers, bool isPublic, string password, string gameMode, string map)
{
Dictionary<ELobbyOptionKey, string> dataDic = new()
{
{ ELobbyOptionKey.GameMode, gameMode },
{ ELobbyOptionKey.Map, map },
};
(bool success, Lobby lobby) = await lobbyManager.UpdateLobby(lobbyName, maxPlayers, isPublic, password, dataDic);
if (success)
{
lobbyManager.PrintLobbyInfo();
}
}
[Command]
async void UpdatePlayer(string playerId, string playerName)
{
Dictionary<EPlayerOptionKey, string> playerDic = new()
{
{ EPlayerOptionKey.PlayerName, playerName },
};
(bool success, Lobby lobby) = await lobbyManager.UpdatePlayer(playerId, playerDic);
if (success)
{
lobbyManager.PrintLobbyInfo();
}
}
[Command]
async void DeleteLobby()
{
bool success = await lobbyManager.DeleteLobby(lobbyManager.CurLobby.Id);
if (success)
{
Debug.Log($"Lobby deleted!");
}
}
[Command]
async void DeleteLobbyWithId(string lobbyId)
{
bool success = await lobbyManager.DeleteLobby(lobbyId);
if (success)
{
Debug.Log($"Lobby deleted!");
}
}
[Command]
async void KickPlayer(string playerId)
{
bool success = await lobbyManager.KickPlayer(playerId);
if (success)
{
Debug.Log($"Player kicked! {playerId}");
}
}
[Command]
async void GetLobby(string lobbyId)
{
(bool success, Lobby lobby) = await lobbyManager.GetLobby(lobbyId);
if (success)
{
lobbyManager.PrintLobbyInfo();
}
}
[Command]
async void GetJoinedLobbyIds()
{
(bool success, List<string> lobbyIds) = await lobbyManager.GetJoinedLobbyIds();
if (success)
{
Debug.Log($"GetJoinedLobbyIds count:{lobbyIds.Count}");
foreach (var lobbyId in lobbyIds)
{
Debug.Log($"LobbyId:{lobbyId}");
}
}
}
[Command]
async void ReconnectLobby()
{
bool success = await lobbyManager.ReconnectLobby();
if (success)
{
Debug.Log($"Lobby reconnected! {lobbyManager.CurLobby.Id}");
}
}
[Command]
void PrintLobbyInfo()
{
lobbyManager.PrintLobbyInfo();
}
}
QuickCommand (https://goranitv.tistory.com/33) 에셋으로 명령어 처리를 할 수 있습니다.
테스트 코드위에 [Command] 어트리뷰트를 넣었습니다.