유니티

유니티 게이밍 서비스 로비 마스터하기 / Master Unity Gaming Services Lobby UGS

고라니TV 2024. 12. 28. 02:33

유니티 게이밍 서비스 로비(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] 어트리뷰트를 넣었습니다.