내일배움캠프/프로젝트

팀프로젝트 - 프로토타입 로비 완성, Vivox 연동 시작

서보훈 2024. 11. 29. 20:57

레디 기능, 방 떠나기 기능, 강퇴기능을 로비에 추가하고, 게임 시작시 Relay를 통해 연결하는 내용까지 구현하여 프로토타입 로비를 완성하였습니다.

 

위의 사진과 같이 게스트 참여자의 경우 Ready 버튼이 활성화 되며, Ready 버튼을 누를경우 준비가 완료되었다는 뜻으로 체크 이미지가 표기됩니다.

호스트의 경우, Ready 버튼 위치에 Kick 버튼이 활성화되며, 이 버튼을 누를경우 참여자가 강퇴되게 됩니다.

 

또한 호스트 플레이어의 경우 Kick, Ready 버튼이 모두 비활성화 됩니다.

 

또한 우측 아래 Leave Lobby 버튼을 눌러서 로비에서 나갈 수 있으며 호스트가 로비를 떠날경우 로비 게스트 참여자도 강제로 로비에서 내보내는 방식으로 제작하였습니다.

또한 게임 시작 버튼의 경우 모든 참여자가 준비된 상태에서만 활성화 되게 만들어주었으며

게스트의 경우 게임 시작 버튼 자체가 보이지 않게 만들어주었습니다.

 

현재 Vivox에 연결하여 음성채팅 시스템을 만들고 있으며, 연결 자체는 구현되었고 음성채팅의 볼륨 조절등의 기능을 추가하기 위해 Vivox를 공부하고 있는중 입니다.

 

아래는 구현 내용입니다.

 


구현 내용

로비에서 나가는 기능 구현입니다.

//로비 떠나기
public async void LeaveLobby()
{
    if (nowLobby == null) return;

    //호스트일경우, 로비 자체가 사라지도록
    if(IsLobbyhost())
    {
        for (int i = 1; i < nowLobby.Players.Count; i++)
        {
            await LobbyService.Instance.RemovePlayerAsync(nowLobby.Id, nowLobby.Players[i].Id);
        }
    }

    try
    {
        await LobbyService.Instance.RemovePlayerAsync(nowLobby.Id, AuthenticationService.Instance.PlayerId);
        nowLobby = null;
        OnLeaveLobbyEvent?.Invoke();
    }
    catch(Exception ex)
    {
        Debug.Log(ex);
    }
}

호스트 여부를 확인하여 호스트일경우 먼저 로비에 참가중인 게스트 플레이어를 나가게 만들어주었으며 이후 자신이 로비에서 나가도록 만들어주었습니다.

 

Lobby 의 공식문서 내용상, 로비에 플레이어가 접속해있지 않을경우 생성된 로비는 자동으로 삭제되도록 구현되어있다고 합니다.

 

또한 로비에서 나가면 이벤트를 발생시켜 UI를 방 검색 UI로 변경되도록 만들어주었습니다.

 

 

강퇴기능 구현입니다.

public async void KickPlayer(string id)
{
    if (!IsLobbyhost()) return;
    if (id == AuthenticationService.Instance.PlayerId) return;

    try
    {
        await LobbyService.Instance.RemovePlayerAsync(nowLobby.Id, id);
    }
    catch (LobbyServiceException ex)
    {
        Debug.Log(ex);
    }
}

호스트가 아닐경우 작동하지 않으며, 선택한 슬롯의 플레이어ID를 받아서 그 ID를 로비에서 제거하는 방식으로 작동합니다.

 

또한 강퇴된 플레이어는 자신이 강퇴되었을경우 GetLobbyAsync 를 통해 로비 정보를 받아오지 못하고 403Forbidden 오류가 발생하게 되는데, 이 오류가 발생했을경우 강퇴된것으로 처리하여 catch 에서 UI를 변경하도록 수정해주었습니다.

아직은 강퇴되었을때 UI 가 준비되지 않았기 때문에 로비를 나갔을때와 작동방식이 같습니다.

※ 이 내용은 RefreshLobbyInfo 메서드로 Update문에서 로비 상태를 업데이트 하는 메서드 입니다.

try
{
    Lobby lobby = await LobbyService.Instance.GetLobbyAsync(nowLobby.Id);
    nowLobby = lobby;

    if (nowLobby.Players.Count > 1)
    {
        for (int i = 0; i < nowLobby.Players.Count; i++)
        {
            if (!bool.Parse(nowLobby.Players[i].Data["IsPlayerReady"].Value))
            {
                isReady = false;
                break;
            }
        }
    }
    else
    {
        isReady = false;
    }

    OnPlayerReadyEvent?.Invoke(isReady);

    //로비의 GameStartKey 가 자신과 다르면 키 내용으로 릴레이에 참가
    if (nowLobby.Data["GameStartKey"].Value != "Waiting")
    {
        if(!IsLobbyhost())
        {
            //호스트가 아니면, 키 내용으로 릴레이 참가
            RelayManager.Instance.JoinRelay(nowLobby.Data["GameStartKey"].Value);
        }

        vivoxChannelName = nowLobby.Name;

        //로비에서 나가기
        nowLobby = null;
        //게임 시작 이벤트 호출
        OnGameStartEvent?.Invoke();
        return;
    }
}
catch (LobbyServiceException ex)
{
    //로비정보 업데이트중, 403 오류 발생시 강퇴 or 로비가 사라졌음 처리
    if(ex.Reason == LobbyExceptionReason.Forbidden)
    {
        nowLobby = null;
        OnKickedFromLobbyEvent?.Invoke();
        return;
    }
}

 

 

로비에서 Ready 기능입니다.

//호스트가 아니면 준비버튼 사용 가능
public async void SetReady( bool isReady)
{
    if (nowLobby == null) return;

    string readyOption = isReady.ToString();
    string playerId = AuthenticationService.Instance.PlayerId;

    UpdatePlayerOptions playerOptions = new UpdatePlayerOptions();
    playerOptions.Data = new Dictionary<string, PlayerDataObject>()
            {
                {"PlayerName", new PlayerDataObject(PlayerDataObject.VisibilityOptions.Member, playerName)},
                {"IsPlayerReady", new PlayerDataObject(PlayerDataObject.VisibilityOptions.Member, readyOption) }
            };

    try
    {
        var lobby = await LobbyService.Instance.UpdatePlayerAsync(nowLobby.Id, playerId, playerOptions);
        nowLobby = lobby;

        RefreshLobbyInfo();
    }
    catch (LobbyServiceException e)
    {
        Debug.Log(e);
    }
}

호스트일경우 이 기능을 사용할 수 없으며, 정보 프리팹의 버튼을 누르면 이 함수가 작동하도록 만들어주었습니다.

또한 준비 상태를 확인하기 위해서 로비의 플레이어 데이터에 IsPlayerReady 키를 추가해주었습니다.

//로비 생성, 일단 무조건 비밀방으로
public async void CreateLobby()
{
    string lobbyName = roomCodeInput.text;

    CreateLobbyOptions createOption = new CreateLobbyOptions
    {
        Player = GetPlayer(true),
        IsPrivate = true,

        //참여자에게 게임 시작을 알리는 키 생성
        Data = new Dictionary<string, DataObject>
        {
            {"GameStartKey", new DataObject(DataObject.VisibilityOptions.Member, "Waiting") }
        }
    };

    Lobby lobby = await LobbyService.Instance.CreateLobbyAsync(lobbyName, maxPlayer, createOption);

    nowLobby = lobby;

    //로비 참가시 UI작동용
    OnJoinLobbyEvent?.Invoke(nowLobby);
    OnPlayerReadyEvent?.Invoke(false);
}

//플레이어 정보 받아오기
private Player GetPlayer(bool isCreate)
{
    string readyOption = isCreate.ToString();

    return new Player(
        id: AuthenticationService.Instance.PlayerId,
        data: new Dictionary<string, PlayerDataObject>()
        {
            {
                "PlayerName", new PlayerDataObject(PlayerDataObject.VisibilityOptions.Member, playerName)
            },
            {
                "IsPlayerReady", new PlayerDataObject(PlayerDataObject.VisibilityOptions.Member, readyOption)
            }

        });
}

로비에 플레이어 정보를 생성할 때, 호스트는 IsPlayerReady 가 true 인 상태로 만들어지며, 참가자는 false 상태로 만들어집니다.

이후 SetReady 메서드에서 Player 클래스의 Data를 변경하여 준비상태 여부를 바꿀 수 있습니다.

 

 

게임 시작부 입니다.

public async void GameStart()
{
    if(!IsLobbyhost()) return;

    try
    {
        //릴레이 코드 가져오기
        string relayCode = await RelayManager.Instance.CreateRelay();

        Lobby lobby = await Lobbies.Instance.UpdateLobbyAsync(nowLobby.Id, new UpdateLobbyOptions
        {
            //로비의 GameStartKey 를 릴레이 코드로 변경
            Data = new Dictionary<string, DataObject>
            {
                {"GameStartKey", new DataObject(DataObject.VisibilityOptions.Member, relayCode) }
            }
        });

        nowLobby = lobby;
        vivoxChannelName = nowLobby.Name;

        OnGameStartEvent?.Invoke();
    }
    catch (LobbyServiceException e)
    {
        Debug.Log(e);
    }
}

로비의 GameStartKey의 Value 값을 릴레이로부터 받은 키 값으로 변경하며 게임을 시작하게 됩니다.

 

이후 OnGameStartEvent 를 발생시켜 Vivox 채널 입장, 씬 넘기기를 작동하게됩니다.

//로비의 GameStartKey 가 자신과 다르면 키 내용으로 릴레이에 참가
if (nowLobby.Data["GameStartKey"].Value != "Waiting")
{
    if(!IsLobbyhost())
    {
        //호스트가 아니면, 키 내용으로 릴레이 참가
        RelayManager.Instance.JoinRelay(nowLobby.Data["GameStartKey"].Value);
    }

    vivoxChannelName = nowLobby.Name;

    //로비에서 나가기
    nowLobby = null;
    //게임 시작 이벤트 호출
    OnGameStartEvent?.Invoke();
    return;
}

게스트의 경우 RefreshLobbyInfo()가 호출될 때, GameStartKey 값이 기본값이 아닐경우 게임이 시작되었음을 알고 호스트로부터 릴레이 키를 받아서 호스트에게 연결을 시도합니다.

 

릴레이 매니저 입니다.

public class RelayManager : MonoSingleton<RelayManager>
{
    //씬을 넘어가지 않도록 Awake 재정의
    protected override void Awake()
    {
        Create();

        if (Instance != this)
        {
            Destroy(gameObject);
        }
    }

    public async Task<string> CreateRelay()
    {
        try
        {
            var playerCount = LobbyManager.Instance.GetNowLobby().MaxPlayers - 1;
            Allocation allocation = await RelayService.Instance.CreateAllocationAsync(playerCount);

            string joinCode = await RelayService.Instance.GetJoinCodeAsync(allocation.AllocationId);

            //TODO : NetworkManager 연동
            NetworkManager.Singleton.GetComponent<UnityTransport>().SetHostRelayData
                (
                allocation.RelayServer.IpV4,
                (ushort)allocation.RelayServer.Port,
                allocation.AllocationIdBytes,
                allocation.Key,
                allocation.ConnectionData
                );

            NetworkManager.Singleton.StartHost();

            return joinCode;
        }
        catch(RelayServiceException e)
        {
            Debug.Log(e);
            return null;
        }
    }

    public async void JoinRelay(string joinCode)
    {
        try
        {
            JoinAllocation joinAllocation = await RelayService.Instance.JoinAllocationAsync(joinCode);

            //TODO : NetworkManager 연동
            NetworkManager.Singleton.GetComponent<UnityTransport>().SetClientRelayData
                (
                joinAllocation.RelayServer.IpV4,
                (ushort)joinAllocation.RelayServer.Port,
                joinAllocation.AllocationIdBytes,
                joinAllocation.Key,
                joinAllocation.ConnectionData,
                joinAllocation.HostConnectionData
                );

            NetworkManager.Singleton.StartClient();
        }
        catch (RelayServiceException e)
        {
            Debug.Log(e);
        }
    }
}

호스트의 경우 CreateRelay() 메서드를 실행하여 릴레이 키를 생성하고, 네트워크 매니저를통해 호스트로 서버를 생성합니다.

 

게스트의 경우 JoinRelay() 메서드를 통해 호스트의 릴레이 서버에 연결하며, 키는 로비 데이터의 GameStartKey를 통해 받아서 서버에 연결합니다.

 

네트워크 매니저는 NetCode 에서 지원하며, Unity Transport 스크립트를 같이 가지고 있어야 작동합니다.

 

Vivox 내용입니다.

public class VivoxController : MonoSingleton<VivoxController>
{
    public event Action OnVivoxLoginEvent;
    private string nowChannelName;

    private void Start()
    {
        LobbyManager.Instance.OnGameStartEvent += SetupVivox;
    }

    public async void SetupVivox()
    {
        await VivoxService.Instance.InitializeAsync();

        await LoginVivoxAsync();

        OnVivoxLoginEvent?.Invoke();
    }

    private async Task LoginVivoxAsync()
    {
        LoginOptions options = new LoginOptions();

        options.DisplayName = AuthenticationService.Instance.PlayerName;

        await VivoxService.Instance.LoginAsync(options);
    }

    public async void JoinVoiceChannel(string channelName)
    {
        nowChannelName = channelName;
        await VivoxService.Instance.JoinGroupChannelAsync(channelName, ChatCapability.AudioOnly);
    }
}

게임 시작 버튼을 누르면 이벤트를 통해 SetupVivox() 메서드가 작동하며, 비복스 서비스에 연결을 시도합니다.

InitializeAsync() 메서드를 통해 Vivox를 초기화하고, 이후 LoginVivoxAsync 메서드에서 Vivox에 로그인을 시도합니다.

 

Vivox에 로그인할때는 AuthenticationService를 통해 익명으로 로그인한 정보를 넘겨주어 Vivox 시스템에 로그인하게 됩니다.

 

이후, 게임씬이 시작되면 JoinVoiceChannel() 메서드를 호출하여 음성채팅 채널에 접속하게됩니다.

음성채팅 채널의 이름은 로비의 이름과 같도록 만들어주었습니다.

 


트러블슈팅

  • 게스트에게도 킥 버튼이 보이는 현상

UI 생성시, 호스트의 킥 버튼을 비활성화 하기만 하고 게스트 입장에서 비활성화를 시도하지 않아서 발생하였습니다.

private void JoinLobby(Lobby lobby)
{
    bool isHost = false;

    //...

    if(AuthenticationService.Instance.PlayerId == lobby.HostId)
    {
        StartButton.gameObject.SetActive(true);
        isHost = true;
    }
    else
    {
        StartButton.gameObject.SetActive(false);
    }

    //...

    for(int i = 0; i < lobby.Players.Count; i++)
    {
        LobbyUIPlayerInfo info = Instantiate(infoPrefab, infoPerant);
        info.SetPlayerInfo(lobby.Players[i], lobby.Players[i].Id == lobby.HostId);

        if(!isHost)
        {
            info.ActiveKickButton(false);

            info.ActiveReadyButton(lobby.Players[i].Id == AuthenticationService.Instance.PlayerId);
        }
        else
        {
            info.ActiveReadyButton(false);
        }
    }
}

UI를 생성한 뒤, 자신이 게스트일경우 킥 버튼을 무조건 비활성화 하도록 추가로 수정하여 게스트는 킥 버튼을 볼 수 없게 만들어주었습니다.

 

아래의 ActiveReadyButton() 메서드가 다음 트러블 슈팅과 연관됩니다.

 

  • 레디가 풀리는 현상

레디를 누르고 UI 가 업데이트되는 시점, 레디가 풀리는 현상을 확인하였습니다.

 

이전에 로비의 Players 에 직접 접근하여 Data를 직접 수정하도록 시도하였으니, 공식문서를 통해 옵션 수정법을 바꿔주었습니다.

 

플레이어 옵션을 수정할 경우, UpdatePlayerOptions 클래스로 옵션을 다시 짜준 후, LobbyService.Instance.UpdatePlayerAsync() 에서 로비 아이디, 자신 아이디, 변경한 옵션 을 매개변수로 다시 지정해주어야 합니다.

또한 이 옵션 수정은 자기 자신의 아이디만 수정이 가능합니다.

 

//호스트가 아니면 준비버튼 사용 가능
public async void SetReady( bool isReady)
{
    if (nowLobby == null) return;

    string readyOption = isReady.ToString();
    string playerId = AuthenticationService.Instance.PlayerId;

    UpdatePlayerOptions playerOptions = new UpdatePlayerOptions();
    playerOptions.Data = new Dictionary<string, PlayerDataObject>()
            {
                {"PlayerName", new PlayerDataObject(PlayerDataObject.VisibilityOptions.Member, playerName)},
                {"IsPlayerReady", new PlayerDataObject(PlayerDataObject.VisibilityOptions.Member, readyOption) }
            };

    try
    {
        var lobby = await LobbyService.Instance.UpdatePlayerAsync(nowLobby.Id, playerId, playerOptions);
        nowLobby = lobby;

        RefreshLobbyInfo();
    }
    catch (LobbyServiceException e)
    {
        Debug.Log(e);
    }
}

 

단, 이 내용으로 변경한 이후로도 오류가 고쳐지지 않았습니다.

이유는 로비 내용을 업데이트 하면서 기존 UI를 삭제하고 새로운 UI를 생성해주었는데, 새로 생성된 UI는 내부의 레디 정보가 초기화되면서 내부적으로는 Ready 상태가 되었어도 UI상으로는 레디가 안된것처럼 보이는것이였습니다.

public void SetPlayerInfo(Player player, bool isHostId)
{
    if (isHostId)
    {
        hostText.text = "Host";
        kickButton.SetActive(false);
    }
    else
    {
        hostText.text = "Guest";
        kickButton.SetActive(true);
    }

    playerNameText.text = player.Data["PlayerName"].Value;
    playerId = player.Id;

    if (bool.Parse(player.Data["IsPlayerReady"].Value) && isHostId == false)
    {
        isReady = true;
        readyImage.SetActive(true);
    }
}

이후 생성될때 Player의 정보를 받아와서 레디 상태에 따라 생성값이 달라지도록 수정하여 문제를 해결하였습니다.

 

  • 게임 시작을 눌러도 씬이 넘어가지 않는 문제

이벤트를 통해 게임을 시작한다는것을 잊고, GameStart 메서드 최하단부에 Vivox에 연결하는 메서드를 실행했었습니다.

Vivox에 연결하는 메서드를 이벤트에 연결한 뒤, Vivox의 연결이 끝나면 게임 시작을 하도록 수정해주었습니다.

 

 

또한, 게임 시작시 nowLobby 값이 Null이 되는데 이를 인지하지 못하고 로비의 이름을 넘겨주기위해 nowLobby 에 접근한결과 Null Exception 오류가 발생하였습니다.

 

이를 인식한 후, 이벤트를 실행하기 전 로비 매니저에 로비 이름을 저장한 뒤, 이를 씬을 넘길때 넘겨주어 Vivox 채널 이름을 만들도록 수정해주었습니다.

//씬 변경
public void ChangeGameScene()
{
    //임시로 테스트씬 하나 추가
    NetworkSceneChanger.Instance.ChangeScene("VivoxTestScene_SBH", vivoxChannelName);
}

해야할 일  목록

  • Vivox 참여자 목록을 확인하고, 이를 사용하여 음량조절등 통신 설정 만들기
  • 게임 시스템적으로 통신을 잠깐 끄는 방법을 찾아보기(무전기를 필요할때 사용할 수 있어야함)
  • Vivox 내용 공부하고 통신관련 내용 완성하기...아직 Vivox 공부가 부족해서 어떤것을 만들지 감이 안잡힙니다...