본문 바로가기

Unity 개발 공부

[내배캠] 본캠 57 pun2 공부5

RPC와 RaiseEvent

RPC는 원격 프로시져 호출(Remote Procedure Calls)
복습,
원격호출할 메서드는
[PunRPC] 속성 적용

 


[PunRPC]
void ChatMessage(string a, string b)
{
    Debug.Log(string.Format("ChatMessage {0} {1}", a, b));
}



RPC로 표기된 함수를 호출하기위해선 PhotonView 컴포넌트가 필요하다.
호출예시


PhotonView photonView = PhotonView.Get(this);
photonView.RPC("ChatMessage", RpcTarget.All, "jup", "and jup!");


뒤에 인자들(jup, andjup!은 PunRPC를 붙인 메서드의 파라메터로 받는 인자들 값을 넣어준것임.
해당 파라미터는 메서드것과 시그니처가 동일해야함, 아니라면 에러가 기록됨
스크립트가 MonoBehaviourPun이라면 this.photonView.RPC()를 사용가능.

PhotonView는 RPC의 타켓과 같다.
모든 클라이언트들은 특정 PhotonVIew가 있는 네트워크 게임 오브젝트의 메서드만을 실행.
RPC 메서드 마지막 파라미터가 PhotonMessageInfo 타입이 될수있는데, 개별호출에 대한
컨텍스트를 제공한다. 호출할때는 PhotonMessageInfo를 설정하지않는다.


[PunRPC]
void ChatMessage(string a, string b, PhotonMessageInfo info)
{
    // the photonView.RPC() call is the same as without the info parameter.
    // the info.Sender is the player who called the RPC.
    Debug.Log(string.Format("Info: {0} {1} {2}", info.Sender, info.photonView, info.timestamp));
}



이렇게 뒤에 PhotonMessageInfo info를 붙이면
info.Sender 라던가 info.photonView, info.teimstamp 등을 쓸수있다.
info.Sender는 이 RPC를 호출한 플레이어를 뜻함.

타켓,버퍼링과순서
어떤 클라이언트가 RPC를 실행할지 정의가 가능한데
RpcTarget의 값을 사용하여 RPC를 실행할 클라인터를 정의한다.
대부분의 경우 ALL로 설정하고 모든 클라이언트가 RPC호출을 할 수 있도록한다.
때로는 Others로 일부부만 할수있도록 정의한다(나를 빼고)
RpcTarget은 Buffered로 끝나는 몇개의 값이 있고
서버는 이러한 RPC들을 기억하고있어 새로운 플레이어가 참여했을때 이전에 발생했던것까지 포함하여
RPC를 얻는다. 이러한 것을 사용할때는 긴 버퍼때문에 참여시간이 느려질  수 있다는 것을 주의하기.
RpcTarget은 ViaServer로 끝나는 값이 있는데,
일반적으로 전송 클라이언트가 RPC를 실행해야만 할때 직접하게 한다. - 서버를 통해 RPC 전송없이.
이렇게하면 로컬에서 메소드를 호출할때 지연이없기때문에 이벤트의 순서에 영향이 있을 수 있음.
ViaServer는 "All" 숏컷을 사용할 수 없도록한다.
RPC는 서버를 통해 전송되고 모든 수신 클라이언트에 의해서 동일 순서로 실행된다.
순서는 서버에 도착하는 순서다.

만약 원격 클라이언트가 로드되지않았거나 아직 매칭되는 PhotonView를 생성하지못했다면 RPC는 손실된다.
이때문에 RPC 손실에대한 가장 큰 원인은 Scene을 로드할때인데, 다른클라이언트가 씬 로드를 못하면
이 클라이언트들은 RPC를 알 수 가없다(동일한 씬을 로드하기전까지)
Pun에서는 PhotonNetwork.automaticallySyncScene = true를 설정해서 접속전에 룸의 마스터클라이언트에게
PhotonNetwork.LoadLevel()을 사용하면 이 문제가 해결될 수 있다.


private IEnumerator MoveToGameScene()
{
    // Temporary disable processing of futher network messages
    PhotonNetwork.IsMessageQueueRunning = false;
    Application.LoadLevel(levelName);
}


RPC 전송실패를 방지하기위해 클라이언트가 보내오는 RPC의 실행을 중지가능한데,
위와 같이 IsMessageQueueRunning = false로 설정한뒤 LoadLevel을 시행하면 큐가 잠금해제될때까지는
메시지의 송수신이 지연될것이다. 다음단계에서 큐를 잠금해제하는거도 당연히 해줘야한다.





RaiseEvent
일부 경우에는 RPC가 원하는 기능이 아닐수도있음
RPC는 PhotonView와 호출할 메서드가 필요하다.
PhotonNetwork.RaiseEvent로 자신만의 이벤트를 구성할 수 있으며 네트워크객체에 관계없이
이벤트를 전송할 수 있다.
이벤트는 고유식별자인 이벤트코드를 사용하여 묘사하는데, 바이트로 된 256개의 이벤트중,
내장으로 이미 쓰고있는 이벤트를 제외하면 [0..199]까지 200개의 사용자 지정 이벤트코드를 사용할 수 있음.
아래는 여러 유닛이 특정위치로 이동하는 이벤트를 쏘는 예제다.


byte evCode = 0; // Custom Event 0: Used as "MoveUnitsToTargetPosition" event
object[] content = new object[] { new Vector3(10.0f, 2.0f, 5.0f), 1, 2, 5, 10 }; // Array contains the target position and the IDs of the selected units
RaiseEventOptions raiseEventOptions = new RaiseEventOptions { Receivers = ReceiverGroup.All }; // You would have to set the Receivers to All in order to receive this event on the local client as well
SendOptions sendOptions = new SendOptions { Reliability = true };
PhotonNetwork.RaiseEvent(evCode, content, raiseEventOptions, sendOptions);


사용자정의 이벤트가 여러개있는경우 다음과 같이 사용된 클래스에서 정의하는게 좋다
private readonly byte MoveUnitsToTargetPositionEvent = 0;
RaiseEvent기능을 호출할때 이 이벤트코드를 할당한 변수를 넣어서 쓸수있다.
PhotonNetwork.RaiseEvent(MoveUnitsToTargetPositionEvent, content, raiseEventOptions, sendOptions);
위에서 object[] 를 사용햇는데 Pun은 어떤 종류의 컨텐츠도 직렬화 가능, 서로다른 유형이 있을경우
객체의 배열을 사용한뒤 OnEvent단계에서(받을때) 형변환을 해서 사용한다.

혹은 해당 이벤트코드들만을 관리하는 클래스를  만들수도있음. 아래는 예제다.


public static class EventCodes
{
    //-----------------------MissionCode-------------------------//
    public const byte MissionsAssigned = 1;
    public const byte MissionCompleted = 2;
    public const byte MissionsAssignedCompleted = 3;
    public const byte MissionCompletedUIRefresh = 4;
    
    
    //-----------------------PlayerCode-------------------------//
    public const byte PlayerSpawn = 100;
    public const byte PlayerJump = 101;
    public const byte PlayerKill = 102;
    public const byte PlayerDied = 103;
    public const byte PlayerAttacked = 104;
    public const byte PlayerReport = 105;
    public const byte PlayerVote = 106;
    
    
    //----------------------GameManageCode----------------------//
    
    public const byte VoteResultKill = 196;
    public const byte SetVoteTime = 197;
    public const byte VoteResult = 198;
    public const byte ChangeState = 199;
    
    //----------------------PopupUICode----------------------// 
    public const byte GameEnded = 11;
}
이렇게 정의하고,, 
예를들어 MissionManager 에서
 public void AssignMissions(string playerKey, int count)
 {
     if (playerMissions.ContainsKey(playerKey)) return;

     var rng = new System.Random();
     var selected = allMissions
         .OrderBy(_ => rng.Next())
         .Take(count)
         .Select(proto => proto.Clone())
         .ToList();

     playerMissions[playerKey] = selected;
     var ids = selected.Select(m => m.MissionID).ToArray();
     PhotonNetwork.RaiseEvent(
         EventCodes.MissionsAssigned,
         new object[] { playerKey, ids },
         new RaiseEventOptions { Receivers = ReceiverGroup.All },
         SendOptions.SendReliable);
 }



RaiseEventOptions에 대해서 얘기해보자,


서버에 이벤트를 캐시할지, 수신할 클라이언트를 지정할지, 혹은 관심그룹을 선택할 수 도있음.
기본값인 RaseEventOptions의 null을 사용할 수도있음.
발신자도 이 이벤트 수신을 원하면 Receivers = ReceiverGroup.All 를 해준다.
Receivers = ReceiverGroup.All (모두)
Receivers = ReceiverGroup.Others (보낸사람제외)
Receivers = ReceiverGroup.MasterClient (호트스/방장)
TargetActor 타입은 int[]로 특정 플레이어 ActorNumber배열만 지정해서 전송도 가능
InterestGroup 타입은 byte 그룹번호(0~255)지정 해당그룹에 가입한 클라이언트만 수신.
Cachingoption 타입은 EventCaching 서버/로비에 이벤트를 캐시하는 방법지정: DoNotCache, AddToRoomCache, AddToRoomCacheGlobal
SequenceChannel 타입은 byte 내부시퀀스 채널번호지정
ForwardToWebhook 타입은 bool PhotonWebHook으로 이벤트 전달여부
WebhookFlags 타입은 WebhookFlags 어떤 WebHook 이벤트를 보낼지 플래그지정
예시: 특정플레이어에게만 보내기.


var opts = new RaiseEventOptions {
    TargetActors = new int[] { 3, 7 },       // ActorNumber 3번과 7번만
    CachingOption = EventCaching.DoNotCache  // 캐시는 하지 않음
};
PhotonNetwork.RaiseEvent(evCode, content, opts, sendOptsReliable);

 



예시: 그룹별 수신설정


// InterestGroup = 5번 그룹에 가입된 클라이언트만 받음
var opts2 = new RaiseEventOptions {
    Receivers = ReceiverGroup.All,
    InterestGroup = 5
};
PhotonNetwork.RaiseEvent(evCode, content, opts2, sendOptsUnreliable);


기본적으로는 모든 클라이언트는 InterestGroup 0에 가입되어있어 InterestGroup=0이면 무조건 수신된다.
InterestGroup>0을 설정하면 해당 그룹에 구독한 클라이언트만 이벤트를 받게된다.
그룹가입방법으로는


// 1) 한 번에 여러 그룹 토글 (bulk update)
byte[] disable = null;        // 빼고 싶은 그룹 (null이면 건너뜀)
byte[] enable  = new byte[]{5};  // 5번 그룹만 구독
PhotonNetwork.SetInterestGroups(disable, enable);

// 2) 단일 그룹 토글
PhotonNetwork.SetInterestGroups(5, true);   // 5번 그룹 구독
PhotonNetwork.SetInterestGroups(0, false);  // (불가능) 그룹 0은 항상 구독
이런식으로 SetIntersetGroup을 통해 가능하다.
두가지 오버로드 SetInterstGroups에대해서 간단하게 보완해서 설명하자면,
PhotonNetwork.SetInterestGroups(byte[] disableGroups, byte[] enableGroups)
에서는
disableGroups: 구독 해제할 그룹 번호들의 배열
enableGroups: 구독할 그룹 번호들의 배열
일때, 
disableGroups에 적힌 그룹은 수신 차단(언subscribe)
enableGroups에 적힌 그룹은 수신 허용(구독)
그예시로
// 그룹 1,2번은 수신 차단, 그룹 5번은 수신 허용
PhotonNetwork.SetInterestGroups(
    new byte[]{1,2},    // disable
    new byte[]{5}       // enable
);


두번째 오버로드에서는
PhotonNetwork.SetInterestGroups(5, true); 이런식인데 5번그룹을 구독한다는 뜻으로
public static void SetInterestGroups(byte group, bool enabled)이렇게도 쓰인다는 뜻.
true → 해당 그룹 구독(Subscribe)
false → 해당 그룹 구독 해제(Unsubscribe)
로 쓰인다.

CachingOptions는 미리 캐쉬를 저장하는걸로 새로운사용자들이 방에 들어올떄
 해당 수신을 받을수있도록해주는 옵션이다.
EventCaching.AddToRoomCache
 EventCaching.AddToRoomCacheGlobal(이게 좀더 안전한옵션)
새로들어올때 너무많은 메시지를 받으면 시간이많이 걸림. 관련없는걸 정리할때는
EventCaching.RemoveFromRoomCache


SequenceChannel (byte)은 이벤트의 순서 보장(ordering) 범위를 채널별로 분리합니다.
SequenceChannel은 기본적으로 0으로 되어있다 따로 지정하지않는다면말이다.
모든 이벤트가 채널0에서 순서보장(ordering) 규칙을 공유한다.
채널구분을 하지않으면 채팅,이동,상태업데이트등 모든 종류의 이벤트가 채널0이라는 한 파이프로 들어와
서로 영향이 있을수있음. 성능 튜닝이필요하면 적절히 SequenceChannel을 분리해주는것이 좋다.
예제는 아래와같다.


var opts = new RaiseEventOptions {
    SequenceChannel = 2,  // 채널 2 내에서만 순서 보장
    Receivers       = ReceiverGroup.All
};
PhotonNetwork.RaiseEvent(moveEventCode, moveData, opts, SendOptions.SendReliable);


SequenceChannel=2 로 움직임(order-sensitive) 이벤트를 묶고,
SequenceChannel=3 로 채팅(order 보장 불필요) 이벤트를 묶으면, 채팅 지연이 움직임 처리에 영향을 주지 않습니다.


SendOptions는 이 이벤트가 신뢰할수있는지 없는지 여부선택, 암호화여부선택이 가능.
SendOptions sendOptions = new SendOptions { Reliability = true };
여기서 true면 신뢰전송 Reliable UDP로 
패킷손실시 재전송을 보장한다. 느리지만 중요한 데이터(아이템 획득, 중요상태변경)에 적합.
false: 비신뢰전송(Unreliable UDP)
빠르지만,일부손실가능. 빈번히 갱신되는 위치 동기화 등에 적합.
Channel(byte)
내부적으로 UDP 포트를 분리할때 사용하는 채널번호
기본값은 0이고 필요할때 0~255사이 값 지정가능.
사용예시는 아래와같다.


// 신뢰 전송, 기본 채널(0) 사용
SendOptions sendOptsReliable = new SendOptions { Reliability = true };
PhotonNetwork.RaiseEvent(evCode, content, raiseOpts, sendOptsReliable);

// 비신뢰 전송, 채널 1 사용
SendOptions sendOptsUnreliable = new SendOptions {
    Reliability = false,
    Channel = 1
};
PhotonNetwork.RaiseEvent(evCode, content, raiseOpts, sendOptsUnreliable);



Channel(byte)에 대해서 잠깐 얘기해보자면,
내부적으로 가상파이프를 분리해 대역폭,파편화, 신뢰성을 다르게 설정해서 큰 메시지가 작은메시지를
막지않게 해준다. Photon에서는 실제로 여러 UDP 소켓을 열지않고 하나의 포트내에서 논리적 채널을 구분해
처리하는데, 각 채널별로 이 메시지최대크기, 재조립, 순서보장(ordering) 같은설정을 
PhotonServerSettings ->Channels에서 달리해줄수있다.
0번채널 혹은 생략은 보통 기본이고 신뢰전송 용으로 쓰고있음.
채널1에 unreliable & undordered로 설정해두고, 쓰게되면
움직임 업데이트같이 빈번하고 최신값만 중요한 이벤트를 채널 1로 보내서 성능을 높일 수 있는것이다.

unreliable & undordered는 값이 아무래도 싸다.
한번 정리해보자면 해당 sendOptions의 Channel 기능을 세팅하는것을 통해 이동에대해서 상황에 맞게 쓸수있는데
프레임 단위로 최신 좌표만 업데이트하면서 뿌린다면(계속움직임실시간) → Unreliable & Unordered (최신값만 중요)
스킬을발동하거나, 유닛이동명령, 미션등으로 인한 자동이동등 목적지가 있고 중간지점등이있는 중요한 이동 명령(상태 변화)이라면 → Reliable & Ordered + SequenceChannel 분리를 해볼수있다.
참고로 SequenceChannel은 헷갈리면안되는게 sendOptions와는 별개로 쓰이는데, 이거는 같은채널내에서 있는애들끼리는
순서가보장되는 기능이다. SequenceChannel은 말그대로 채널분리라서, 다른채널간 간섭이나
블로킹이없고, 한채널의 지연이 다른채널처리에 영향을 주지않게 만드는것이다.
즉 SeuqenceChannel로 채널분리해주고 sendOptions를 추가로 설정해주면 특정 시퀀스채널에서
순서를 보장할건지 안할건지, 신뢰전송할건지 비신뢰전송할건지 구분해서 쓸수있는것이다.
이아래 예제가있다.


PhotonNetwork.RaiseEvent(
    evCode,
    data,
    new RaiseEventOptions   { SequenceChannel = 2, Receivers = ReceiverGroup.All },
    new SendOptions         { Reliability    = true, Channel = 0 }
);

 



참고로 PunRPC의 경우 기본적으로 Reliable & Ordered로 보낸다.
내부적으로도 RaiseEvent 호출하지만 RPC 이름 조회,리플렉션 오버헤드가 추가된다.
RaiseEvent가 좀더 커스터마이징가능하고 바이트+오브젝트배열을 직접직렬화해서 오버헤드 감소가 가능.

따라서 중요 명령은 RPC, 빈번한 갱신은 RaiseEvent로 써보는것도 좋으며
둘은 독립적이어서 같이써도 충돌이 없다.
물론 캐릭터움직임같은것은 PhotonView + Transform 동기화 컴포넌트를 이용해
내부적으로 Unreliable & Unordered로 빠르게 전송을 할 수있기때문에 두가지(PunRPC, RaiseEvent)를 안써도된다.
PhotonView와 OnPhotonserializeView를 통해 보간,지연보상을 하면서 쓸수도있다.
결론: TPS 같은 매프레임 위치·회전 동기화는
PhotonTransformView, PhotonRigidbodyView(물리가있을경우)
 또는 OnPhotonSerializeView(지연보상,보간등 직접제어) 를 쓰는 게 표준이다.





다음은 OnEvent다.
해당 커스텀이벤트를 받기위해서는 우리는 
IOnEventCallback인터페이스를 구현하고, 이를 구현할때 OnEvent 콜백 핸들러를 추가한다.
이핸들러 함수는 아래와 같은 틀을 가지고있다.


public void OnEvent(EventData photonEvent)
{
    // Do something
}


이 핸들러를 올바르게 등록하기위해 Unity의 OnEnable, OnDisable 함수를 사용해주면된다.
아래와 같은 함수를 적어줘야만 해당 클래스에서 OnEvent를 쓸수있다.


public void OnEnable()
{
    PhotonNetwork.AddCallbackTarget(this);
}

public void OnDisable()
{
    PhotonNetwork.RemoveCallbackTarget(this);
}
앞선 여러명에게 위치값을 보내는 예제를 받는 예제는 아래와같다. (mission 관련 말고)
public void OnEvent(EventData photonEvent)
{
    byte eventCode = photonEvent.Code;

    if (eventCode == MoveUnitsToTargetPositionEvent)
    {
        object[] data = (object[])photonEvent.CustomData;

        Vector3 targetPosition = (Vector3)data[0];

        for (int index = 1; index < data.Length; ++index)
        {
            int unitId = (int)data[index];

            UnitList[unitId].TargetPosition = targetPosition;
        }
    }
}


먼저 수신된 이벤트 코드가 이전에 설정한 코드와 일치하는지확인한다.
일치한다면 우리는 이전에 보낸 형식에 이벤트내용을 캐스팅하는것이다. 아까 객체배열로 보냈기때문에
이후 해당 배열의 전체 데이터를 형변환 해주고. 그안에서 다시 배열인덱스로 0번의경우
위치값이니까 Vector3로 형변환해서 꺼내서 할당하고,
나머지 1번째부터 데이터의 오브젝트배열길이만큼 순회해서,
유닛리스트에 담긴 각 유닛의 번호로 할당해준다. 그다음 그 유닛의 포지션을 해당 0번인덱스로 받은 데이터 (위치값)
을 넣어서 할당해주는것이다.

유닛리스트는 직접정의한 콜랙션으로 유닛오브젝트들을 조회하기위해 사용하는데


public class UnitManager : MonoBehaviour {
    // 유닛을 숫자 키(예: unitId)로 찾기 위한 딕셔너리
    public static Dictionary<int, Unit> UnitList = new Dictionary<int, Unit>();
}


이런식으로 했다.
해당 유닛을 담을때


public class Unit : MonoBehaviour {
    // 유닛이 움직여야 할 목표 지점
    public Vector3 TargetPosition { get; set; }
    
    void Update() {
        // 단순 예시: 매 프레임 목표 지점 쪽으로 이동
        transform.position = Vector3.MoveTowards(transform.position,
                                                 TargetPosition,
                                                 Time.deltaTime * moveSpeed);
    }
}


이런식으로 정의해서 프로퍼티로 꺼내서 쓰는상황이다.



커스텀프로퍼티에대해서 알아보자.
Photon의 커스텀 프로퍼티는 키-값의 해시테이블로 구성되어있어 필요시에 사용할 수 있다.
플레이어나 룸에 임의의 데이터를 붙여서 네트워크 상의 모든 참가자가 해당 데이터를 동기화 
할수있게하는 키-값 기반 공유저장소다.

값은 클라이언트에서 동기화되고 캐시되기때문에 사용하기전에 패치(fetch)할 필요없다.
변경사항은 SetCustomProperties()를 통해 다른플레이어들에게 푸시된다.
일반적으로 룸과 플레이어들은 게임오브젝트와 관계없는 속성들을 가짐.
이러한 속성들(예를들면 맵이나 캐릭터 색상)은 객체동기화 또는 RPC 통해도 전송할수있지만
커스텀프로퍼티를 쓰는게 더 편리하다.
SetCustomProperties()가 호출되면 서버에서 Reliable RPC로 전달되서
서버가 값을 버퍼에 저장한뒤 이후 새로 입장하는 플레이어에게도 자동으로 초기화된다.
따라서 룸 또는 플레이어의 현재 상태를 원본처럼 보관하고 다른 클라이언트가 조인해도
자동으로 최신상태를 받게 할때 매우 적합하다. 변경된 키만 REliable로 전송하기때문에
불필요한 데이터 재전송을 최소화한다.
단점으로는 모든 변경이 Reliable RPC -> 따라서 오버헤드가 좀있고, 빈번한 변경에는 적합하지않다.
서버가 버퍼를 관리하므로 대규모 룸에서 너무 많은 키를 써버리면 메모리,처리에 부담이 있다.
RaiseEvent/OnEvent는 불필요한 버퍼링없이 지금 이순간만 알리고, reliable,Unreable, 채널번호, 타겟지정등이 가능하다.
버퍼되지않으니까 서버메모리부담도 최소일테다 다만 새로입장한 플레이어에게 과거이벤트를 주지는않는다.
Reliable옵션을 쓰면 CustomProperties처럼 확실히 전송되기는하겠다만, 버퍼링은 되지않는것이다.

PhotonPlayer.SetCustomProeprties(Hastable propsToset)을 사용하여 추가 또는 변경하기위해
Key-value를 설정해준다.
PhotonNetwork.LocalPlayer는 나 자신을 가리킨다.
유사하게
PhotonNetwork.CurrentRoom.setCustomProperties(Hastable propsToset)을 사용하여
참가하고 있는 룸을 업데이트할 수 도 있다.
Pun에서는 해당 프로퍼티들이 변경되었을때 콜백으로
OnRoomPropertiesUpdate(Hashtable propertiesThatChanged)또는
OnPlayerPropertiesUpdate(Player targetPlayer, Hastable changedProps)를 각각 호출한다.

그 쓰임에는 매칭후 플레이어 상태(레디/언레디)
선택한 팀정보
룸설정(맵,게임모드)처럼 런타임 중 변경되며 모두가 알아야할 정보를 관리하기 위함이다.
ExitGames.Client.Photon.Hashtable 이걸쓰는데, Photon 내부 프로토콜에 최적화된 경량사전이다.
일반 System.Collections.hashtable과 달리 바이트단위 직렬화/역직렬화를 지원한다.
IDictionary<object, object>를 구현한다.

Photon.Realtime.Player
pun2에서 룸에 참가한 각 클라이언트를 나타내는 객체
player.CustomProperties 프로퍼티로 조회가능하다.

Photon.Realtime.Room
현재참여중인 게임 룸을 나타내는 객체
Room.CustomProperties 프로퍼티로 조회가능하다.
PhotonNetwork.LocalPlayer, PhotonNetwork.CurrentRoom 이런식으로 전역접근 가능하다.


Player Custom Properties 기본 사용법은 아래와같다.


using Exitgames.Client.Photon;
using Photon.Pun;
using Photon.Realtime;

var props = new Hashtable();
props["ready"] = true; //ready라는 키에 true라는 bool 값 저장했음
PhotonNetwork.LocalPlayer.setCustomProperties(props);

 



이렇게 로컬플레이어에 적용할 수 있다.
키는 string을 추천하며 값은 byte, int ,float, bool, string 등 직렬화 가능한 타입으로 간다.

조회 방법으로는
bool isReady = (bool)PhotonNetwork.LocalPlayer.CustomProperties["ready"];

변경감지콜백으로는


public override void OnPlayerPropertiesUpdate(Player targetPlayer, Hashtable changedProps)
{
 if(changedProps.ContainsKey("ready"))
  {
   bool newReady = (bool)changedProps["ready"];
  // todo: ui 갱신 등 처리
  }
}


Room Custom Properties 기본사용법으로도 비슷


var roomProps = new Hashtable();
roomProps["map"] = "DesertArenea";
//룸에 적용
PhotonNetwork.CurrentRoom.SetCustomPRoperties(roomProps);


PhotonNetwork.CurrentRoom
현재 접속중인 룸 객체
SetCustomProperties()
모든 참가자에게 전파

조회방법
string mapName = (string)PhotonNetwork.CurrentRoom.CustomProperties["map"];
//형변환 해주는 이유는 해당 CustomProperties 타입이 ExitGames.Client.Photon.Hashtable이기때문에
내부적으로 IDictionary<object, object>를 구현하고있고
인덱서로 꺼내는 값의 형식은 object로 반환되기때문에 형변환해주는것이다.

프로퍼티초기화는 룸입장등에서
PhotonNetwork.CurrentRoom.CustomProperties.Clear();  해주면된다.
OnJoinedRoom() 이후에 동기화타이밍으로 설정해주고
방을 나갈때는 자동으로 클리어되므로 별도 처리 불필요하다.

아래의 예제를 보자.


using System;
using ExitGames.Client.Photon;
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

[Serializable]
public class CharacterProperties : IOnPhotonSerializeCallback
{
    public string CharacterName;
    public int Health;
    public float Speed;
    public Color SkinColor;

    public CharacterProperties(string name, int health, float speed, Color color)
    {
        CharacterName = name;
        Health = health;
        Speed = speed;
        SkinColor = color;
    }

    public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) ...
//이것은 예시일뿐 실제로는 모든 CharacterProperties의 변수값들을 이렇게 다 계속 전송하지는않음
//심지어 이것들은 PhotonView가 붙어있어야만하는 오브젝트에서만 적용되므로
//PhotonView 동기화 주기에 따라
//IPunObservable(또는 IOnPhotonSerializeCallback) 인터페이스가 붙은 컴포넌트가
//자동으로 OnPhotonSerializeView를 호출하는 로직이므로 모든값들을 계속 저렇게 보내게되면 오버헤드가 일어남.
//실제로는 이중에 위치값같은것을 연동한다. 캐릭터 스텟같은거는 잘연동안하고 커스텀프로퍼티로 초기화 후에는, RPC등으로 혹은
//RaiseEvent로 체력이나 스피드등을 바꾸는 일을하게될것이다.
    {
        if (stream.IsWriting)
        {
            stream.SendNext(CharacterName);
            stream.SendNext(Health);
            stream.SendNext(Speed);
            stream.SendNext(SkinColor.r);
            stream.SendNext(SkinColor.g);
            stream.SendNext(SkinColor.b);
            stream.SendNext(SkinColor.a);
        }
        else
        {
            CharacterName = (string)stream.ReceiveNext();
            Health = (int)stream.ReceiveNext();
            Speed = (float)stream.ReceiveNext();
            float r = (float)stream.ReceiveNext();
            float g = (float)stream.ReceiveNext();
            float b = (float)stream.ReceiveNext();
            float a = (float)stream.ReceiveNext();
            SkinColor = new Color(r, g, b, a);
        }
    }
}

public class GameManager : MonoBehaviourPunCallbacks
{
    public void CreateRoom(string roomName, string mapName)
    {
        Hashtable roomProps = new Hashtable
        {
            { "map", mapName },
            { "timeLimit", 180 },
            { "weather", "Rainy" }
        };
        RoomOptions options = new RoomOptions
        {
            MaxPlayers = 4,
            CustomRoomProperties = roomProps,
            CustomRoomPropertiesForLobby = new string[] { "map", "weather" } //로비에서 먼저 보여줄수있도록 설정을 빼줌
        };
        PhotonNetwork.CreateRoom(roomName, options, TypedLobby.Default);
    }

 // 로비에 접속하거나 룸 리스트가 갱신될 때 자동 호출되는 콜백
    // roomList: Photon 서버가 제공하는 현재 로비의 RoomInfo 목록
    public override void OnRoomListUpdate(List<RoomInfo> roomList)
    {
        // 이 메서드는 MonoBehaviourPunCallbacks가 제공하는 내장 콜백입니다.
        // Photon 서버가 로비(Room List)를 갱신할 때 자동으로 호출하며,
        // roomList 파라미터는 Photon이 관리하는 모든 활성 룸(RoomInfo 객체) 목록을 포함합니다.
        // 별도로 List<RoomInfo>를 생성하거나 CustomRoomPropertiesForLobby로 정의하는 것이 아니라,
        // Photon 내부에서 제공해 줍니다.
        
        // CustomRoomPropertiesForLobby에 지정된 키(map, weather)만
        // 각 RoomInfo.CustomProperties에 포함되어 전달됩니다.
        foreach (RoomInfo info in roomList)
        {
            // 로비(UI) 단계에서 보여주고 싶은 방 목록 정보
            string rMap = info.CustomProperties.ContainsKey("map")
                ? (string)info.CustomProperties["map"]
                : "Unknown";
            string rWeather = info.CustomProperties.ContainsKey("weather")
                ? (string)info.CustomProperties["weather"]
                : "Clear";
            Debug.Log($"[Lobby] Room: {info.Name}, Map={rMap}, Weather={rWeather}, Players {info.PlayerCount}/{info.MaxPlayers}");
        }
    }, Map={rMap}, Weather={rWeather}, Players {info.PlayerCount}/{info.MaxPlayers}");
        }
    }

    // 방 입장 시 호출, CustomRoomProperties에는 모든 키가 포함됨
    public override void OnJoinedRoom()
    {
        base.OnJoinedRoom();
        string map = (string)PhotonNetwork.CurrentRoom.CustomProperties["map"];
        int timeLimit = (int)PhotonNetwork.CurrentRoom.CustomProperties["timeLimit"];
        string weather = (string)PhotonNetwork.CurrentRoom.CustomProperties["weather"];
        Debug.Log($"[Joined] Room on map {map}, timeLimit {timeLimit}s, weather {weather}");
    }

    public void SetCharacterProperties(CharacterProperties charProps)
    {
        byte[] bytes = SerializeCharacter(charProps);
        Hashtable playerProps = new Hashtable { { "charData", bytes } };
        PhotonNetwork.LocalPlayer.SetCustomProperties(playerProps);
    }

    public override void OnPlayerPropertiesUpdate(Player targetPlayer, Hashtable changedProps)
    {
        if (changedProps.ContainsKey("charData"))
        {
            byte[] data = (byte[])changedProps["charData"];
            CharacterProperties props = DeserializeCharacter(data);
            Debug.Log($"Player {targetPlayer.NickName} loaded character {props.CharacterName} with HP {props.Health}");
            // 적용: transform, stats, UI 등
        }
    }

    byte[] SerializeCharacter(CharacterProperties cp)
    {
        using (var ms = new System.IO.MemoryStream())
        {
            var bf = new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter();
            bf.Serialize(ms, cp);
            return ms.ToArray();
        }
    }

    CharacterProperties DeserializeCharacter(byte[] data)
    {
        using (var ms = new System.IO.MemoryStream(data))
        {
            var bf = new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter();
            return (CharacterProperties)bf.Deserialize(ms);
        }
    }
}


여기서 RoomOptions라는 api도 포톤에서 제공해주는데,
RoomOptions 프로퍼티는 어떻게 동기화되나?


RoomOptions options = new RoomOptions {
    MaxPlayers = 8,
    IsOpen     = true,
    IsVisible  = true,
    // …생략…
};
PhotonNetwork.CreateRoom(roomName, options, TypedLobby.Default);


이런식으로 RoomOptions를 만들고 CreateRoom단계에서 변수로 넣어주면
 서버가 방을 만들때 내부적으로 저장하고
클라이언트가 룸에 조인하면 자동으로 해당정보가 전달되는것이다.

접근방법은
var room = PhotonNetwork.CurrentRoom;
Debug.Log($"MaxPlayers: {room.MaxPlayers}, IsOpen: {room.IsOpen}, IsVisible: {room.IsVisible}");
등이 가능하다. 이값들은 CustomRoomProperites 해시테이블에 넣을필요가없다.
이 커스텀프로퍼티라는것 자체가 사용자 정의로 키와 값들을 구성해주는것이기에,
서버관리 옵션으로 이미 제공되고있는 RoomOptions는 따로 넣을필요없이 해당 api를 사용하면된다.
맨위의 장문의 예제에서는, 섞어서 쓰고있다.

호스트가 선택한 옵션으로 RoomOptions 만들기도 예제를 띄워보자.
위의 예제는 하드코딩한 값들을 이미 넣어놓은상태였다.


using UnityEngine;
using UnityEngine.UI;
using ExitGames.Client.Photon;
using Photon.Pun;
using Photon.Realtime;

public class LobbyUI : MonoBehaviourPunCallbacks
{
    [Header("UI References")]
    public InputField roomNameInput;        // 방 이름 입력 필드
    public Dropdown mapDropdown;            // 맵 선택 드롭다운
    public Slider maxPlayerSlider;          // 최대 플레이어 수 선택 슬라이더
    public Text maxPlayerLabel;             // 슬라이더 값 표시 텍스트
    public Toggle openToggle;               // 방 오픈 여부 토글
    public Toggle visibleToggle;            // 로비 노출 여부 토글

    void Start()
    {
        // 슬라이더 값이 바뀔 때마다 레이블 갱신
        maxPlayerSlider.onValueChanged.AddListener(UpdateMaxPlayerLabel);

        // (선택) 맵 드롭다운 초기화
        mapDropdown.ClearOptions();
        mapDropdown.AddOptions(new List<string> { "DesertArena", "ForestRuins", "SpaceStation" });
        
        // 초기 레이블 세팅
        UpdateMaxPlayerLabel(maxPlayerSlider.value);
    }

    void UpdateMaxPlayerLabel(float value)
    {
        maxPlayerLabel.text = $"Max Players: { (int)value }";
    }

    // UI 버튼의 OnClick에 연결
    public void OnCreateRoomButton()
    {
        string roomName    = roomNameInput.text;
        string selectedMap = mapDropdown.options[mapDropdown.value].text;
        byte   maxPlayers  = (byte)maxPlayerSlider.value;
        bool   isOpen      = openToggle.isOn;
        bool   isVisible   = visibleToggle.isOn;

        // (1) Room Custom Properties
        Hashtable roomProps = new Hashtable
        {
            { "map", selectedMap }
        };

        // (2) RoomOptions 세팅
        RoomOptions options = new RoomOptions
        {
            MaxPlayers                   = maxPlayers,
            IsOpen                       = isOpen,
            IsVisible                    = isVisible,
            CustomRoomProperties         = roomProps,
            CustomRoomPropertiesForLobby = new string[] { "map" }
        };

        // (3) 방 생성
        PhotonNetwork.CreateRoom(roomName, options, TypedLobby.Default);
    }
}