[Unity] Photon RPC 입력 동기화

limce·2025년 4월 20일

Unity

목록 보기
6/6
post-thumbnail

Photon RPC로 플레이어 입력 동기화하기

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

GameObject rpcManagerObject = PhotonNetwork.Instantiate("RPCManager", Vector3.zero, Quaternion.identity);
  • PhotonNetwork.Instantiate()는 호출한 클라이언트 외에도 모든 클라이언트에 객체를 생성한다.

Received RPC "RPCManager" for viewID 1001 but this PhotonView does not exist! 경고

처음에는 이렇게 RPCManager를 프리팹으로 만들어서 동적으로 생성했는데,

Received RPC "RPCManager" for viewID 1001 but this PhotonView does not exist!

다음과 같은 경고가 발생했다.
RPC 호출은 왔는데 해당 ViewID를 가진 PhotonView가 아직 존재하지 않는다는 의미로, 네트워크 동기화가 완료되기 전에 RPC가 먼저 도착해버린 상황에서 자주 발생하는 문제다.

해결

RPCManager를 굳이 동적으로 생성할 이유가 없기 때문에, 씬에 배치하여 정적으로 생성되도록 변경하였다.
1. 정적 객체는 PhotonView와 네트워크 초기화가 동시에 이루어진다.

  • 이렇게 하면 게임 시작 시 객체가 바로 생성되고, PhotonView도 즉시 활성화되어 다른 클라이언트와 동기화가 이루어진다.
  • PhotonNetwork.Instantiate()는 네트워크 상에서 객체를 동적으로 생성하면서 각 클라이언트의 동기화 시간이 필요하다.
    정적으로 생성하면 네트워크 초기화와 동시에 동기화가 완료되기 때문에 이 문제를 피할 수 있다.
  1. RPC 호출 시 PhotonView의 ViewID가 이미 동기화되어 있다.
    PhotonNetwork.Instantiate() 호출 시 할당되는 ViewID가 모든 클라이언트에 동기화되지 않았을 수 있다. 즉, PhotonView가 아직 네트워크 상에 존재하지 않은 상태에서 RPC가 도착할 수 있다.
    정적으로 생성하면 씬 로드 시 ViewID가 동기화되므로, 네트워크 상의 다른 클라이언트가 이미 해당 PhotonView를 인식하고 있는 상태에서 RPC가 호출되어 경고가 발생하지 않는다.

코드1 (버그 발생)

처음 작성한 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 = "";
  • 자주 쓰이는 RPC들만 모아 관리하는 클래스이기 때문에, 하나의 인스턴스만 존재하도록 싱글톤으로 만들어주었다.
  • PhotonVeiw의 멤버에 접근할 때에는 photonView로 직접 접근해도 되지만, 클래스 특성상 자주 사용해야 하므로 편의성을 위해 PV로 줄여 사용하려고 한다.
  • E키를 입력한 플레이어의 actorNumber를 저장하기 위해 중복을 허용하지 않는 HashSet타입 readPlayers 필드를 선언했다.
  • 플레이어 접속 여부, 컷씬이 완료 여부 등 다음 씬으로 넘어갈 수 있는 조건이 다 다르기 때문에 이를 확인하기 위한 canAcceptReady 필드를 선언했다.
  • 매번 넘어가는 씬이 다르기 때문에 이를 설정할 수 있는 sceneName 필드를 선언했다.
void Update()
    {
        if (!PhotonNetwork.InRoom || !canAcceptReady || !PV)
            return;

        if (Input.GetKeyDown(KeyCode.E))
        {
            PV.RPC("MarkReady", RpcTarget.All, PhotonNetwork.LocalPlayer.ActorNumber);
        }
    }
  • 플레이어가 방에 들어가 있는 상태이고 canAcceptReady가 true이고 photonView가 null이 아닐 때 E키를 입력을 실행하려고 한다.
  • E키를 입력 시 E키를 누른 플레이어의 actorNumber를 전달받은 MarkReady를 모든 클라이언트가 실행한다.
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 함수는 MatchManagerOnPlayerEnteredRoom, OnPlayerLeftRoom에서 호출된다.
  • OnPlayerEnteredRoom: 새로운 플레이어가 방에 들어왔을 때 기존에 방에 있던 플레이어들에게만 실행된다.
    OnPlayerLeftRoom: 같은 방에 있던 플레이어가 나갔을 때 방에 남은 플레이어들에게만 실행된다.
  • 2인 게임이기 때문에 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키 입력 시 호출되는 함수로, readPlayers에 E키를 입력한 플레이어의 actorNumber를 추가한다.
  • readPlayers는 중복 값을 허용하지 않으므로 만약 readPlayers의 크기가 최대 플레이 인원 수와 동일하다면 모든 플레이어가 들어왔다는 것이므로, 이 때 다음 씬으로 넘어가고 그 후 readPlayers 값은 비운다.

버그

그러나 해당 코드 실행 시 마스터 클라이언트의 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);
  • MarkReady의 RPCTarget도 MasterClient도 바꿔주었다.
  • LoadLevel은 마스터만 호출 가능하기 때문에
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일 때 실행하면 겉으로는 비슷하게 동작하기는 하지만, 안정성 측면에서 차이가 있다.

OnJoinedRoomOnPlayerEnteredRoom
호출 주체새로 들어온 사람 (나)기존 룸 참가자 (마스터)
호출 시점들어오는 순서에 따라 조금 불안정할 수 있다.항상 마스터 기준, 단일 주체가 흐름 제어
중복 가능성두 명 모두 들어올 때 동시에 조건 만족 ➡️ RPC 중복 가능성 존재마스터만 호출하므로 안정적

0개의 댓글