Unity

Photon - 동기화에 대한 이해 (1)

temp-franc 2023. 11. 6. 23:14

** 로비 화면 / 룸 화면을 구성하면서 겪었던 사례를 통해 느낀 점을 서술하였습니다.

 

I. 동기화의 범위

 

동기화

 

 : 게임의 여러 플레이어 간 또는 클라이언트와 서버 간의 상태 및 데이터를 일관되게 유지하는 것

 => 이 일관되게 유지하는 것의 범위를 잘 이해하고, 효율적인 수단을 사용함으로써 더 좋은 동기화를 유지할 수 있다.

 

 

AutomaticallySyncScene

 

  : 해당 옵션이 true인 경우, 룸에 참가한 클라이언트들은 모두 마스터 클라이언트(호스트)와 같은 레벨(씬)을 load하게 된다.

 

 PhotonNetwork.AutomaticallySyncScene = true;

 

 

MonoBehaviourPun

 

    : 포톤 클라우드 서비스와 연결된 Unity 애플리케이션에서 네트워크 관련 기능을 활용할 수 있도록 지원.

    : 해당 클래스의 인스턴스화가 이루어진 시점부터, 네트워크 이벤트 및 동기화가 시작됨.

    : 기본적으로 각 클라이언트들은 해당 스크립트의 필드값이 동일하게 유지됨..

 

 

1.. MonoBehaviorPun 클래스에서 정의된 필드는 동기화 된다.

   방에, Ready 버튼이 있고, 이 버튼을 누르면 게임을 시작할 준비상태가 되고, 해당 버튼이 누를 시에 준비 상태가 취소되는 취소 버튼으로 변한다고 생각해보자.

 

 

 

private bool isPlayerReady;

//.. 생략 ..

public void SetPlayerReady(bool playerReady)
{
    if (playerReady)
    {
        ReadyButton.GetComponentInChildren<TextMeshProUGUI>().text = "취소";
        ReadyButton.GetComponentInChildren<Image>().color = new Color(130 / 255f, 241 / 255f, 96 / 255f);
    }
    else
    {
        ReadyButton.GetComponentInChildren<TextMeshProUGUI>().text = "준비";
        ReadyButton.GetComponentInChildren<Image>().color = new Color(255 / 255f, 182 / 255f, 182 / 255f);
    }
}

public void OnReadyButtonClicked()
{
    isPlayerReady = !(bool)isPlayerReady;
    SetPlayerReady((bool)isPlayerReady);
    var props = new Hashtable() { { "IsPlayerReady", isPlayerReady } };
    PhotonNetwork.LocalPlayer.SetCustomProperties(props);
}

 

 

버튼을 눌렀을 시, 버튼의 모양을 바꿀 수 있는, SetPlayerReady() 메서드와 인스펙터에서 준비 버튼에 연결해둔 OnReadyButtonClicked() 메서드를 다음과 같이 설정하고, 버튼을 누른다면 ....

 

 

 

위의 그림과 같이, 한 쪽의 버튼을 눌렀는데 다른 클라이언트의 버튼도 같이 눌린 경험을 하였다.

 

 

 

동기화가 진행되면서, 필드 값 중 하나인 isPlayerReady의 값이 동일하게 유지되어야 하기 때문에 일어난 일이다..

위와 같은 상황에서는 동기화가 필요 없는데, 동기화가 적용된 상황이다.

이런 상황을 제어하기 위해, 커스텀 프로퍼티 혹은 RPC를 사용할 수 있다.

 

2.. 커스텀 프로퍼티

 

  : 플레이어 혹은 룸과 관련된 키-값의 hashtable로 이루어진 추가 정보.

  : 각 클라이언트 별로 따로 저장된다.

 

  커스텀 프로퍼티 (플레이어) 설정

 

var props = new Hashtable() { { "IsPlayerReady", isPlayerReady } };
PhotonNetwork.LocalPlayer.SetCustomProperties(props);

 

  커스텀 프로퍼티 값 확인

 

if (PhotonNetwork.LocalPlayer.CustomProperties.TryGetValue("IsPlayerReady", out object isPlayerReady))
{
    isPlayerReady = !(bool)isPlayerReady;

 

 

    : 필드 값은 동일하게 유지되지만, 커스텀 프로퍼티는 클라이언트별로 설정한 값을 갖기 때문에 해당 값을 통해서 클라이언트 각각의 상태를 분리할 수 있다.

 

   커스텀 프로퍼티 활용 : 직업

 

public enum CharClass
{
    Soldier,
    Shotgun,
    Sniper,
}

if (!playerCP.ContainsKey("Char_Class"))
{
    PhotonNetwork.SetPlayerCustomProperties(
    new ExitGames.Client.Photon.Hashtable()
    { {"Char_Class", CharClass.Soldier} }
    );
}

 

     : 커스텀 프로퍼티를 활용하여, 각 클라이언트가 어떤 직업을 선택하였는 지, 설정하게 하였다.

 

public void SetClassType(int charType, GameObject playerGo = null)
{
    PlayerStatHandler statSO;
    if (playerGo != null)
    {
        Debug.Log($"적용 오브젝트 : {playerGo.name}");
        statSO = playerGo.GetComponent<PlayerStatHandler>();
    }
    else
    {
        Debug.Log($"적용 오브젝트 : PlayerContainer");
        statSO = playerContainer.GetComponentInChildren<PlayerStatHandler>();
    }

    switch (charType)
    {
        case (int)LobbyPanel.CharClass.Soldier:
            statSO.CharacterChange(soldierSO);
            break;
        case (int)LobbyPanel.CharClass.Shotgun:
            statSO.CharacterChange(shotGunSO);
            break;
        case (int)LobbyPanel.CharClass.Sniper:
            statSO.CharacterChange(sniperSO);
            break;
    }
}

 

 

 

    : 이후, 플레이어가 직업을 바꿀 때마다, 커스텀 프로퍼티 또한 반영하게 하여, 해당 커스텀 프로퍼티로 각 직업의 스크립터블 오브젝트에 접근하게 하여 직업 변경을 구현하였다.

 

 

 3.. RPC

 

   : 지정한 대상으로 하여금, 지정한 메서드를 실행시킨다.

   : 지정한 대상의 클라이언트만 실행된다.

 

   다른 클라이언트에 전달.

 

   : 별다른 처리를 하지 않는다면, 단순히 PUN2 에 연결되었다고 해서 게임오브젝트가 동기화되지 않는다.

 

   : 위에서 SetClassType()으로 각 클라이언트의 직업을 해당 클라이언트에서만 변경한 것이다. RPC 혹은, IPunObservable 인터페이스를 활용하여 변경된 사항을 동기화할 수 있다.

 

   : 로비에서 룸으로 입장하였을 때, OnJoinedRoom() 에서

     PhotonNetwork.Instantiate() 를 통하여 클라이언트의 플레이어 오브젝트를 인스턴스화 시켜주고, 

     로비에서 선택한 직업을 photonView.RPC()로 다른 클라이언트에 전달하였다.

 

public override void OnJoinedRoom()
{
    Debug.Log($"{PhotonNetwork.LocalPlayer.NickName} 입장");
    //... 생략 ...

	// 오브젝트 인스턴스화
    instantiatedPlayer = InstantiatePlayer();
    viewID = instantiatedPlayer.GetPhotonView().ViewID;
    instantiatedPlayer.GetComponent<ClassIdentifier>().playerData = playerDataSetting.GetComponent<PlayerDataSetting>();

    // 로비에서 선택한 직업을 다른 클라이언트에서도 적용 (RPC)
    object classNum;
    PhotonNetwork.LocalPlayer.CustomProperties.TryGetValue("Char_Class", out classNum); 
    instantiatedPlayer.GetComponent<PhotonView>().RPC("ApplyClassChange", RpcTarget.Others, (int)classNum, viewID);
}

public GameObject InstantiatePlayer()
{
    GameObject playerPrefab = playerContainer.transform.GetChild(0).gameObject;
    playerPrefab.name = "Pefabs/Player";
	
    // 오브젝트 인스턴스화
    PlayerDataSetting playerData = playerDataSetting.GetComponent<PlayerDataSetting>();
    GameObject playerNet = PhotonNetwork.Instantiate(playerPrefab.name, Vector3.zero, Quaternion.identity);
	
    // 로비에서 선택한 직업을 적용 (커스텀 프로퍼티)
    PhotonNetwork.LocalPlayer.CustomProperties.TryGetValue("Char_Class", out object classNum);
    playerData.SetClassType((int)classNum, playerNet);

    Destroy(playerPrefab);
    return playerNet;
}

 

 

     : 동시에, OnPlayerEnteredRoom() 을 활용하여 기존에 방에 있던 각 클라이언트가 선택한 직업을 룸에 들어온 클라이언트에 전달하여, 해당 클라이언트에 있을 기존 클라이언트들의 각 플레이어 특성을 적용시켜 주었다.

 

public override void OnPlayerEnteredRoom(Player newPlayer)
{
    Debug.Log($"{newPlayer.NickName} 입장");

    // ... 생략 
    
    // ... 새로운 룸 가입 클라이언트에게 본인 플레이어의 정보(구현 내용) 전달. (RPC)
    object classNum;
    PhotonNetwork.LocalPlayer.CustomProperties.TryGetValue("Char_Class", out classNum);
    instantiatedPlayer.GetComponent<PhotonView>().RPC("ApplyClassChange", RpcTarget.Others, (int)classNum, viewID);
}

 

 

     : 전달하여 실행시키게한 메서드의 파라미터로 전달하는 클라이언트가 인스턴스화한 플레이어 (= 조작하는 플레이어)의 PhotonView 컴포넌트의 ViewID와 선택한 직업 (커스텀 프로퍼티의 value) 을 추가하였다.

 

     : 파라미터로 해당 정보들을 일종의 상수로 전달하기 때문에, 다른 클라이언트에서도 정확히 원하는 플레이어 오브젝트에 접근하여 메서드에 있는 기능을 구현할 수 있었다.

 

public class ClassIdentifier : MonoBehaviourPunCallbacks
{    
    public PlayerDataSetting playerData;

    private int viewID;
    public void Initialize()
    {
        viewID = photonView.ViewID;
    }

    public void ClassChangeApply(int classNum)
    {
        Initialize();
        playerData.SetClassType(classNum, this.gameObject);
    }

    [PunRPC]
    public void ApplyClassChange(int classNum, int viewID)
    {        
        PhotonView photonView = PhotonView.Find(viewID);
        playerData.SetClassType(classNum, photonView.gameObject);
    }

    public int GetViewID()
    {
        return viewID;
    }
}