현재 3D 2인 멀티 플레이 게임을 개발 중이다.
플레이어 매치 메이킹이 이루어진 후, 두 플레이어 모두 E키를 입력하면 다음 씬으로 넘어가게 하려고 한다. (오버쿡드에서 차용했다.)
해당 기능은 앞으로 게임 진행 중 자주 사용될 예정이기 때문에, 이렇게 자주 쓰이는 RPC들만 모아 관리할 수 있는 RPCManager 싱글톤 클래스를 만들고 그 안에 구현하려고 한다.

GameObject rpcManagerObject = PhotonNetwork.Instantiate("RPCManager", Vector3.zero, Quaternion.identity);
처음에는 이렇게 RPCManager를 프리팹으로 만들어서 동적으로 생성했는데,
Received RPC "RPCManager" for viewID 1001 but this PhotonView does not exist!
다음과 같은 경고가 발생했다.
RPC 호출은 왔는데 해당 ViewID를 가진 PhotonView가 아직 존재하지 않는다는 의미로, 네트워크 동기화가 완료되기 전에 RPC가 먼저 도착해버린 상황에서 자주 발생하는 문제다.
RPCManager를 굳이 동적으로 생성할 이유가 없기 때문에, 씬에 배치하여 정적으로 생성되도록 변경하였다.
1. 정적 객체는 PhotonView와 네트워크 초기화가 동시에 이루어진다.
처음 작성한 RPCManager 코드이다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
public class RPCManager : MonoBehaviourPunCallbacks
{
private static RPCManager instance;
public static RPCManager Instance
{
get
{
if (instance == null)
{
var obj = FindObjectOfType<RPCManager>();
if (obj != null)
instance = obj;
}
return instance;
}
}
private PhotonView PV;
private static HashSet<int> readPlayers = new HashSet<int>(2);
private bool canAcceptReady = false;
private string sceneName = "";
void Awake()
{
PV = GetComponent<PhotonView>();
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
}
else if (instance != this)
{
Destroy(gameObject);
}
}
void Update()
{
if (!PhotonNetwork.InRoom || !canAcceptReady || !PV)
return;
if (Input.GetKeyDown(KeyCode.E))
{
PV.RPC("MarkReady", RpcTarget.All, PhotonNetwork.LocalPlayer.ActorNumber);
}
}
public void SetCanAcceptReady(bool value)
{
canAcceptReady = value;
}
public void SetSceneName(string name)
{
sceneName = name;
}
[PunRPC]
void MarkReady(int actorNumber)
{
if (readPlayers.Contains(actorNumber))
return;
readPlayers.Add(actorNumber);
if (readPlayers.Count == PhotonNetwork.CurrentRoom.MaxPlayers && PhotonNetwork.IsMasterClient)
{
PhotonNetwork.LoadLevel(sceneName);
readPlayers.Clear();
}
}
}
코드를 차근차근 살펴보자.
private static RPCManager instance;
public static RPCManager Instance
{
get
{
if (instance == null)
{
var obj = FindObjectOfType<RPCManager>();
if (obj != null)
instance = obj;
}
return instance;
}
}
private PhotonView PV;
private static HashSet<int> readPlayers = new HashSet<int>(2);
private bool canAcceptReady = false;
private string sceneName = "";
photonView로 직접 접근해도 되지만, 클래스 특성상 자주 사용해야 하므로 편의성을 위해 PV로 줄여 사용하려고 한다.readPlayers 필드를 선언했다.canAcceptReady 필드를 선언했다.sceneName 필드를 선언했다.void Update()
{
if (!PhotonNetwork.InRoom || !canAcceptReady || !PV)
return;
if (Input.GetKeyDown(KeyCode.E))
{
PV.RPC("MarkReady", RpcTarget.All, PhotonNetwork.LocalPlayer.ActorNumber);
}
}
public void SetCanAcceptReady(bool value)
{
canAcceptReady = value;
}
public void SetSceneName(string name)
{
sceneName = name;
}
// MatchManager
public override void OnPlayerEnteredRoom(Player newPlayer)
{
photonView.RPC("UpdateStatusText", RpcTarget.All, "E키를 눌러 게임을 시작하세요.");
RPCManager.Instance.SetCanAcceptReady(true);
RPCManager.Instance.SetSceneName("CharacterSelect");
}
public override void OnPlayerLeftRoom(Player otherPlayer)
{
photonView.RPC("SetPlayer2PanelActive", RpcTarget.All, false);
photonView.RPC("UpdateStatusText", RpcTarget.All, "상대가 나갔습니다. 상대를 기다리는 중...");
RPCManager.Instance.SetCanAcceptReady(false);
}
SetCanAcceptReady, SetSceneName 함수는 MatchManager 내 OnPlayerEnteredRoom, OnPlayerLeftRoom에서 호출된다.OnPlayerEnteredRoom이 실행되면 모든 플레이어(2명)이 입장한 것이므로, 여기서 SetCanAcceptReady(true)를 호출했다.SetSceneName("CharacterSelect") 이 때 설정한다.OnPlayerLeftRoom이 실행되면 현재 방에는 플레이어가 1명인 것이므로 혼자 씬을 넘어갈 수 없도록 SetCanAcceptReady(false)를 호출했다.[PunRPC]
void MarkReady(int actorNumber)
{
if (readPlayers.Contains(actorNumber))
return;
readPlayers.Add(actorNumber);
if (readPlayers.Count == PhotonNetwork.CurrentRoom.MaxPlayers && PhotonNetwork.IsMasterClient)
{
PhotonNetwork.LoadLevel(sceneName);
readPlayers.Clear();
}
}
그러나 해당 코드 실행 시 마스터 클라이언트의 E키만 입력되고, 다른 일반 클라이언트는 E키를 눌러도 아무 반응이 없는 버그가 발생했다.
원인이 무엇인지 고민해본 후 글을 이어서 읽어보자.
싱글톤의 문제인지, View ID의 문제인지, 혹은 RPC 타깃이 문제인지 정말 헤맸는데 우연히 힌트를 얻어 해결하게 되었다.
아직 포톤이 익숙하지 않은 상태에서 아예 실행이 안 되는 것도 아니고 마스터만 실행이 되니 원인을 유추하기 어려웠다.
혹시나 SetCanAcceptReady, SetSceneName의 호출 위치가 문제인가 싶어서 OnJoinedRoom으로 옮겨더니 정상적으로 실행되었다!
여기서 버그 원인의 힌트를 얻었다. OnJoinedRoom에서 실행한 것과 OnPlayerEnteredRoom에서 실행한 것의 차이는 한 가지였다.
앞서 언급했듯, OnPlayerEnteredRoom에서 실행하면 기존에 방에 있던 플레이어, 즉 마스터만 실행하게 된다. 그러나 OnJoinedRoom에서 실행하면 방에 들어가는 플레이어마다 실행한다. 즉, 현재 방에 있는 플레이어는 모두 한 번씩 실행하는 것이다.
결론적으로, 마스터의 canAcceptReady만 true이기 때문에 E키가 실행된 것이고 일반 클라이언트는 false이기 때문에 실행되지 않던 것이다.
그렇지만 이 방법은 플레이어 한 명이 들어갔을 때에도 canAcceptReady가 true가 되기 때문에 의도한 바와는 다르게 동작한다.
[PunRPC]
public void SetCanAcceptReady(bool value)
{
canAcceptReady = value;
}
[PunRPC]
public void SetSceneName(string name)
{
sceneName = name;
}
모든 플레이어의 canAcceptReady가 true가 될 수 있도록, 그리고 모든 클라이언트가 동일한 sceneName을 가지도록 두 함수를 RPC 함수로 만들어주었다.
PV.RPC("MarkReady", RpcTarget.MasterClient, PhotonNetwork.LocalPlayer.ActorNumber);
readPlayers.Count == PhotonNetwork.CurrentRoom.MaxPlayers
씬을 넘기기 위한 조건도 마스터만 확인하면 된다. 따라서 readPlayers를 모든 클라이언트가 공유할 필요가 없기 때문에, MarkReady를 마스터만 호출하고 readPlayers를 마스터만 관리하도록 변경했다.
public override void OnPlayerEnteredRoom(Player newPlayer)
{
photonView.RPC("UpdateStatusText", RpcTarget.All, "E키를 눌러 게임을 시작하세요.");
RPCManager.Instance.photonView.RPC("SetCanAcceptReady", RpcTarget.All, true);
RPCManager.Instance.photonView.RPC("SetSceneName", RpcTarget.All, "CharacterSelect");
}
public override void OnPlayerLeftRoom(Player otherPlayer)
{
photonView.RPC("SetPlayer2PanelActive", RpcTarget.All, false);
photonView.RPC("UpdateStatusText", RpcTarget.All, "상대가 나갔습니다. 상대를 기다리는 중...");
RPCManager.Instance.photonView.RPC("SetCanAcceptReady", RpcTarget.All, false);
}
그리고 두 함수를 OnPlayerEnteredRoom, OnPlayerLeftRoom에서 호출하도록 했다.
RPCTarget이 All이기 때문에 플레이어가 들어오면 이미 방에 있던 플레이어(마스터)가 호출해도 모든 클라이언트가 실행한다.
OnJoinedRoom에서 PhotonNetwork.CurrentRoom.PlayerCount == 2일 때 실행하면 겉으로는 비슷하게 동작하기는 하지만, 안정성 측면에서 차이가 있다.
| OnJoinedRoom | OnPlayerEnteredRoom | |
|---|---|---|
| 호출 주체 | 새로 들어온 사람 (나) | 기존 룸 참가자 (마스터) |
| 호출 시점 | 들어오는 순서에 따라 조금 불안정할 수 있다. | 항상 마스터 기준, 단일 주체가 흐름 제어 |
| 중복 가능성 | 두 명 모두 들어올 때 동시에 조건 만족 ➡️ RPC 중복 가능성 존재 | 마스터만 호출하므로 안정적 |