Photon - 동기화에 대한 이해 (1)
** 로비 화면 / 룸 화면을 구성하면서 겪었던 사례를 통해 느낀 점을 서술하였습니다.
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;
}
}