저번에 이어서 매치메이킹을 완성한다. 그리고, 게임에 접속해서 각 클라이언트들간의 동기화를 하는 방법을 알아본다.
게임 오브젝트들을 동기화 시켜야 한다. 하지만, 모든 게임 오브젝트를 동기화 시킨다면 네트워크 통신량이 너무 많이 필요해진다. 필요한 게임 오브젝트만 동기화 시킴으로써 통신량을 관리할 필요가 있다.
동기화에 쓰이는 포톤의 컴포넌트다.
위의 3가지를 읽고 쓰기 위한 스크립트들을 가지고 있다.
Photon View의 참조 변수를 가지고 있는 스크립트다. 해당 클래스를 상속받아서 동기화가 필요한 게임 오브젝트를 관리한다.
public class PhotonController : MonoBehaviourPun // 게임오브젝트 동기화를 위한 스크립트
{
private void Awake()
{
PhotonView pv = photonView; // MonoBehaviourPun은 photonView를 참조하고 있음
Debug.Log(pv.ToString());
}
}
동기화를 위해서는 모든 클라이언트가 동일한 게임 오브젝트를 생성할 필요가 있다.
네트워크 환경에서 사용될 게임오브젝트 생성을 위해서는 일반적인 Unity.Engine의 Instantiate가 아닌, PhotonNetwork.Instantiate를 사용하게 된다.
PhotonNetwork.Instantiate("PrefabName", position, rotation);
프리팹의 이름을 입력하면, Resources 폴더에서 해당 프리팹의 이름을 찾아 생성한다.
마찬가지로 삭제를 위해서는 Unity.Engine.Destroy가 아니라, PhotonNetwork.Destroy를 사용한다.
룸 안의 모든 클라이언트는, 포톤뷰를 기준으로 같은 게임오브젝트의 삭제를 진행한다.
PhotonNetwork.Destroy(photonView);
다만, 각 클라이언트마다 변수를 저장하는 메모리의 위치는 다르다는 것을 명심해야 한다.
IPunObservable 인터페이스를 상속받는 스크립트는 포톤 뷰에서 동기화할 스크립트로 구성된다.
OnPhotonSerializeView() 함수에서 동기화할 변수를 지정할 수 있다.
반드시 데이터를 보낸 순서대로 받아서 사용해야한다! 보낸 순서와 다르게 받을 경우, 데이터가 불일치한다.
public class PhotonController : MonoBehaviourPun, IPunObservable
{
[SerializeField] int value1;
[SerializeField] float value2;
[SerializeField] bool value3;
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
// 변수 데이터를 보내는 경우
if (stream.IsWriting)
{
stream.SendNext(value1);
stream.SendNext(value2);
stream.SendNext(value3);
}
// 변수 데이터를 받는 경우
// 보낸 순서에 맞게 써야 한다!!!
else if (stream.IsReading)
{
value1 = (int)stream.ReceiveNext(); // 형변환 해줘야 한다
value2 = (float)stream.ReceiveNext();
value3 = (bool)stream.ReceiveNext();
}
}
}
어딘가에 대입한다면, 형변환을 추가해줘야 한다.
앞서 설명했듯이, 변수를 저장하는 위치가 각 클라이언트마다 다를 수 밖에 없기 때문에, 동기화가 가능한 변수는 값 형식으로 제한한다.
| 타입 (C#) | 설명 |
|---|---|
| bool | true or false |
| byte | 부호 없는 정수형 8 bit |
| short | 부호 있는 정수형 16 bit |
| int | 부호 있는 정수형 32 bit |
| long | 부호 있는 정수형 64 bit |
| float | 부동 소수형 32 bit |
| double | 부동 소수형 64 bit |
| string | 최대 32767 (short.MaxValue) 글자 |
| 배열 | 최대 32767 (short.MaxValue) 갯수 |
| 타입(Photon) | 설명 |
|---|---|
| Vector2 | 2 floats |
| Vector3 | 3 floats |
| Quaternion | 4 floats |
| Player | int PhotonPlayer.ID |
참조형 데이터를 동기화할 수 있는 방법이 있다. 포톤 뷰를 통해 게임 오브젝트를 동기화 한다는 것을 이용하는 것이다.
// 포톤 뷰 아이디를 확인
// 포톤 뷰는 모든 클라이언트가 동일하므로 동일한 게임오브젝트를 참조 가능
int id = photonView.ViewID;
// 포톤 뷰 아이디를 기준으로 탐색
// 포톤 뷰 아이디는 미리 캐싱되어 있으므로 빠른 탐색이 가능
PhotonView target = PhotonView.Find(id);
게임 오브젝트, 변수를 동기화 했으니, 동일한 함수를 호출하는 것만 있으면 된다. RPC를 통해 구현한다. 이름 그대로 원격에서 함수를 호출하는 거다.
public class PhotonController : MonoBehaviourPun
{
public void RequestSendChat() // 해당 로컬 클라이언트에 의해 수행되는 함수
{
// RPC 호출 신청. 다른 모든 클라이언트들이 SendChat 함수를 수행한다.
photonView.RPC("SendChat", RpcTarget.All, "userName", "message");
}
// RPC 호출 신청시 클라이언트들이 반응하는 함수
[PunRPC] // RPC로 사용할 함수는 [PunRPC] 어트리뷰트를 지정해야 함
public void SendChat(string userName, string message)
{
Debug.Log($"{userName} : {message}");
}
}
여러 개의 매개변수를 가질 수 있다. 매개변수 지정 순서와 동일하게 다른 클라이언트들에게 매개변수를 전달한다.
추가적으로, PhotonMessageInfo를 매개변수로 추가하면 해당 함수 호출에 대한 정보도 같이 보낼 수 있다.
public class PhotonController : MonoBehaviourPun
{
public void Request()
{
photonView.RPC("MessageInfo", RpcTarget.All, "text", 2, 3.14f);
}
[PunRPC]
public void MessageInfo(string param1, int param2, float param3, PhotonMessageInfo info)
{
Debug.Log(param1); // text
Debug.Log(param2); // 2
Debug.Log(param2); // 3.14f
Debug.Log(info.Sender); // 보낸 플레이어
Debug.Log(info.photonView); // 보낸 포톤 뷰
Debug.Log(info.SentServerTime); // 보낸 서버 시간
}
}
앞서 봤던 RpcTarget에 대해서다. RPC함수를 수행할 클라이언트를 지정할 수 있다.
Buffered : 서버에 RPC들을 기억시킨다. 새로운 플레이어가 참여했을 때, Buffered에 기억된 RPC들을 호출시켜 준다.
ViaServer : 일반적으로 RPC 함수를 수행할 때, 해당 함수를 전송하는 클라이언트에서 직접 진행한다. 이러한 경우, 진행한 클라이언트는 지연시간이 없다는 장점이 생겨서 공정성이 보장되지 않는다.
ViaServer를 통해 RPC를 전송하면, 서버를 한 번 거쳐서 전송한다. 그렇기 때문에, 모든 수신하는 클라이언트는 전달된 RPC를 동일한 순서로 수행한다.
| RpcTarget | 대상 | 서버 기억 여부 | 실행 시점 |
|---|---|---|---|
| All | 모든 클라이언트 | X | |
| Others | 다른 클라이언트 | X | 즉시 실행 |
| MasterClient | 마스터 클라이언트 | X | 즉시 실행 |
| AllViaServer | 모든 클라이언트 | X | 서버를 거쳐 실행 |
| AllBuffered | 모든 클라이언트 | O | 즉시 실행 |
| OthersBuffered | 다른 클라이언트 | O | 즉시 실행 |
| AllBufferedViaServer | 모든 클라이언트 | O | 서버를 거쳐 실행 |
Buffered가 없는 경우photonView.RPC("DoSomething", RpcTarget.All);
이 RPC는 호출 당시 룸에 접속해 있던 플레이어들에게만 전달된다. 이후에 새로운 플레이어가 룸에 입장해도, 이전에 발생한 이 RPC 호출은 전송되지 않는다.
Buffered가 있는 경우photonView.RPC("DoSomething", RpcTarget.AllBuffered);
RPC 함수가 Photon 서버에 "버퍼링"되어 저장된다. 나중에 새로운 플레이어가 같은 룸에 입장하면, 서버가 이 Buffered RPC들을 해당 플레이어에게 순차적으로 재전송한다. 결과적으로 새로 들어온 플레이어의 상태도 기존 플레이어들과 동기화된다!
💡 사용 예시
문 개폐여부, 몬스터 사망여부, 보스 HP 상태 등.
새로 입장한 플레이어도 이 상태를 정확히 알도록 하기 위해 Buffered RPC를 사용한다.
전투 중에 이미 활성화된 함정, 떨어진 아이템, 폭발 이펙트 등.
📌 주의사항
버퍼는 룸 단위로 유지되며, 오브젝트가 Destroy되거나 방을 나가면 해당 RPC 버퍼도 삭제된다.
너무 많은 Buffered RPC를 사용하면 서버 메모리가 증가하고, 새로 들어오는 플레이어가 이를 모두 수신하느라 Join 시간이 길어질 수 있다.
상태를 표현하는 데는 RPC보다 PhotonNetwork.Instantiate와 PhotonView 변수 동기화가 더 적절한 경우가 많음.
클라이언트들이 네트워크 객체에 대해서 서로 다른 데이터를 지정할 경우, 소유권을 데이터의 기준으로 삼아 조정한다.

포톤 뷰의 소유권을 가지고 있는 클라이언트다. PhotonView.IsMine으로 확인가능.
모든 클라이언트는 소유자의 데이터를 기준으로 네트워크 객체의 데이터를 동기화 한다.
소유자가 아닌 네트워크 객체에 대한 조작은 무시한다.
소유권이 없는 네트워크 객체를 조작하면, 해당 내용은 다른 클라이언트들에게 전달이 되지 않는다. 즉, 서버의 상황과 다른 게임상황이 생기는 것이다.
이러한 문제를 막기 위해서 소유권이 없을 경우 해당 네트워크 객체는 동작하지 않도록 구성할 필요가 있다.
private void Update()
{
if (photonView.IsMine == false) // 소유권이 없을 경우 빠져나감
return;
// 포톤 뷰의 소유권이 있는 클라이언트만 실행 가능한 코드
}
네트워크 데이터 전송은 물리적으로 지연 시간이 발생할 수 밖에 없다. 이러한 지연시간을 줄이기 위한, 지연 보상을 알아보자.
RPC를 전송한 시간, 현재 시간의 차이를 통해 지연 시간을 계산할 수 있다. 이 시간차이를 이용해서, 계산 되지 않은 만큼의 오차를 조정해 줄 수 있다.
public void RequestGameStart()
{
photonView.RPC("GameStart", RpcTarget.AllViaServer, PhotonNetwork.Time);
}
[PunRPC]
public void GameStart(PhotonMessageInfo info)
{
// 서버에서 RPC를 보낸 시간과 현재 시간의 격차를 계산
// Abs를 하는 이유는, 가끔 결과 값이 음수가 나올 수도 있기 때문이다.
float lag = Mathf.Abs((float)(PhotonNetwork.Time - info.SentServerTime));
// 시간의 격차만큼 감소된 시간만큼 카운트를 진행
// ex. 지연 시간이 0.1초 발생한 경우 카운트 다운을 4.9초 진행
StartCoroutine(GameTimer(5f- lag));
}
IEnumerator GameTimer(float timer)
{
yield return new WaitForSeconds(timer);
Debug.Log("게임 스타트!");
}
네트워크 객체가 물리 객체(Rigidbody) 일 경우 위치, 속도를 통해서 지연 시간 동안의 움직임을 계산할 수 있다. 지연 시간 X 속도를 추가적으로 이동 거리에 더해주어, 서버의 상황과 일치 시킬 수 있다.
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
if (stream.IsWriting) // 보내는 경우
{
stream.SendNext(rigidbody.position);
stream.SendNext(rigidbody.rotation);
stream.SendNext(rigidbody.velocity);
}
else if (stream.IsReading) // 전송 받는 경우
{
rigidbody.position = (Vector3) stream.ReceiveNext();
rigidbody.rotation = (Quaternion) stream.ReceiveNext();
rigidbody.velocity = (Vector3) stream.ReceiveNext();
float lag = Mathf.Abs((float) (PhotonNetwork.Time - info.timestamp));
rigidbody.position += rigidbody.velocity * lag; //등속도, 한 방향 운동일 때만 적용되는 방식이다.
}
}
속도, 방향 관련해서는 객체의 이동 함수를 가져오도록 한다.
Transform 컴포넌트를 사용해서 동기화 한다. 이전 시점과 현재 시점의 데이터의 차이를 계산하고, 헤당 차이를 지연 시간만큼 조정한다.
private Vector3 networkPosition; // 전송받은 위치
private float deltaPosition; // 위치 변화량
private Quaternion networkRotation; //전송받은 회전
private float deltaRotation; // 회전 변화량
private float interpolatePos;
private float interpolateRot;
private void Update()
{
if (photonView.IsMine == false) // 소유권이 없는 오브젝트일 경우
{
// 이동 및 회전에 보간을 더해준다
interpolatePos = deltaPosition * Time.deltaTime * PhotonNetwork.SerializationRate;
interpolateRot = deltaRotation * Time.deltaTime * PhotonNetwork.SerializationRate;
transform.position = Vector3.MoveTowards(transform.position, networkPosition, interpolatePos);
transform.rotation = Quaternion.RotateTowards(transform.rotation, networkRotation, interpolateRot);
}
}
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
if (stream.IsWriting)
{
stream.SendNext(transform.position);
stream.SendNext(transform.rotation);
}
else if (stream.IsReading)
{
networkPosition = (Vector3)stream.ReceiveNext();
networkRotation = (Quaternion)stream.ReceiveNext();
deltaPosition = Vector3.Distance(transform.position, networkPosition);
deltaRotation = Quaternion.Angle(transform.rotation, networkRotation);
}
}
PUN2의 PhotonHandler에 의해 관리되는 정적(static)변수다.
기본값은 10회/초.
원하면 스크립트 상에서 직접 변경할 수도 있다. 모든 클라이언트가 동일한 값을 공유한다.
필요에 따라 특정 구간(전투, 차량 운전 등)에서 일시적으로 높이고, 평상시에는 낮출 수 있다.
호스트 이주방장이 연결이 해제되는(방을 떠나는) 경우, 다른 클라이언트가 호스트 권한을 승계하는 기법. 게임을 끊김없이 자연스럽게 한다.
게임의 승패 판정, 남은 시간 계산 등등 방장만이 처리하는 로직들이 있다. 이러한 경우를 위해서 마스터 클라이언트를 변경할 필요가 있다.
public class PhotonController : MonoBehaviourPunCallbacks
{
// 마스터 클라이언트 변경시 호출됨
public override void OnMasterClientSwitched(Player newMasterClient)
{
if (PhotonNetwork.LocalPlayer != newMasterClient)
return;
// 변경된 마스터 클라이언트가 자신인 경우
// 방장으로서 해야할 준비 작업 진행
}
}
새로 선정된 마스터 클라이언트는 마스터일 때만 수행해야 하는 로직들을 수행한다.
private void Update()
{
if (PhotonNetwork.IsMasterClient == false)
return;
// 방장만 수행해야하는 로직
}
마스터 클라이언트가 변경되며 기존의 마스터 클라이언트가 생성한 게임 씬에 필요한 게임 오브젝트들은 소유자가 사라지면서 같이 사라지게 되는 상황이 있을 수 있다.
게임 씬에 필요한 경우, 룸 오브젝트로 생성한다. 룸 오브젝트는 마스터 클라이언트가 컨트롤 권한을 가지고 동작 시킨다. 마스터 클라이언트가 방에서 나가더라도, 방에 남아있는 오브젝트다.
동기화는 PhotonView를 통해서 이루어진다. 상태 변경은 OnPhotonSerializeView, RPC로 전송한다.개인 소유가 아닌, 방 소유로 생성하는 것이 특징
PhotonNetwork.Instantiate("Prefab", pos, rot);
생성된 해당 오브젝트는, 생성한 플레이어가 Owner가 된다. 해당 플레이어가 떠나면, 오브젝트는 파괴된다.
PhotonView의 또다른 소유권이다. 방이 소유하는 오브젝트.
PhotonNetwork.InstantiateRoomObject("Prefab", pos, rot);
생성된 해당 오브젝트는 소유권이 방(Room)에 귀속된다.
마스터 클라이언트가 나가더라도 오브젝트가 파괴되지 않음!
마스터가 교체되면, 새 마스터 클라이언트가 컨트롤 권한을 승계받는다.
룸 오브젝트는 마스터만 제어하도록 스크립트 상에서 보장해야 한다!
if (PhotonNetwork.IsMasterClient)
{
// 룸 오브젝트 업데이트 로직 (예: AI 이동, 맵 상태 관리)
}
스플래툰에 비추어 볼 때, 룸 오브젝트는 다음과 같다.
맵에 잉크가 칠해진 구역을 관리하는 잉크 상태 관리자. 마스터가 잉크가 칠해진 상태를 관리한다. 현재 프로젝트 진행 방향으로 생각했을 때는, 맵 자체를 룸 오브젝트로 해야 한다.
공용 아이템 스포너. 아이템 스폰 타이밍을 마스터가 제어한다. 아이템 오브젝트 자체는
PhotonNetwork.InstantiateRoomObject("ItemPrefab", pos, rot);
와 같이 써서 룸 오브젝트로 관리한다.
부족한 플레이어 수 만큼 생성되는 AI 플레이어
전편 포스트에 이어, 매치메이킹 구현을 완료해보자.
방장만 맵을 변경할 수 있다. 좌,우 버튼을 클릭하면 맵이 변경된다.
방과 관련한 권한들은 룸매니저가 담당하므로 수정한다.
[Header("Set Map Settings")]
[SerializeField] private Image mapImage;
[SerializeField] private Sprite[] mapSprites;
[SerializeField] private Button mapLeftBtn;
[SerializeField] private Button mapRightBtn;
private void Start()
{
mapLeftBtn.onClick.AddListener(ClickLeftMapButton);
mapRightBtn.onClick.AddListener(ClickRightMapButton);
}
public void ClickLeftMapButton() // 외부(인스펙터)에서 이벤트를 연결하려면, 퍼블릭일 필요가 있다.
{
mapIndex--;
if (mapIndex == -1) // 첫 번째 맵이였는데 왼쪽 클릭을 했다면, 맨 마지막 맵으로 이동
{
mapIndex = mapSprites.Length - 1;
}
MapChange();
}
public void ClickRightMapButton()
{
mapIndex++;
if (mapIndex == mapSprites.Length)
{
mapIndex = 0;
}
MapChange();
}
public void MapChange() // 좌,우 버튼 클릭 시 맵 변경
{
mapImage.sprite = mapSprites[mapIndex];
}
좌, 우 버튼을 눌러보면 맵은 변경된다(방장만...)
이제 커스텀 프로퍼티를 통해 클라이언트들을 동기화 시켜보자
커스텀 프로퍼티의 자세한 내용은 이전 포스트 참고.
// using 구문에서 미리 지정해놓는다. 그렇지 않으면 C#의 해시테이블을 쓰게 된다.
using Hashtable = ExitGames.Client.Photon.Hashtable;
public void PlayerPanelSpawn()// 내가 새로 입장했을 때 호출. OnJoinedRoom()에서 수행됨.
{
foreach (Player player in PhotonNetwork.PlayerList) // 현재 방에 접속한 모든 플레이어(PlayerList)
{
PhotonNetwork.AutomaticallySyncScene = true; // 동기화. 마스터 클라이언트를 따라 동시에 같은 레벨을 로드함
if (!PhotonNetwork.IsMasterClient) // 본인이 호스트가 아닐 경우, 권한 뺏음
{
startBtn.interactable = false;
mapLeftBtn.interactable = false;
mapRightBtn.interactable = false;
MapChange();
}
// 플레이어 슬롯을 생성해서 패널에 추가함. 초기화 과정
GameObject obj = Instantiate(playerPanelItemPrefabs);
obj.transform.SetParent(playerPanelContoent);
obj.GetComponent<PlayerSlot>().Init(player);
PlayerSlot item = obj.GetComponent<PlayerSlot>(); // 초기화
playerDict.Add(player.ActorNumber,item); // 방에 들어온 순서대로 ActorNumber가 올라감. 재입장해도 새로 배정 받음
}
}
public void ClickLeftMapButton() // 외부(인스펙터)에서 이벤트를 연결하려면, 퍼블릭일 필요가 있다.
{
mapIndex--;
if (mapIndex == -1) // 첫 번째 맵이였는데 왼쪽 클릭을 했다면, 맨 마지막 맵으로 이동
{
mapIndex = mapSprites.Length - 1;
}
Hashtable roomProperty = new(); // namespace 지정을 하지 않으면, C#의 hashTable을 사용함
roomProperty["Map"] = mapIndex; //박싱 언박싱이 어쩔 수 없이 일어남. Photon의 HashTable이 Dictionary<obj,obj>이기때문
PhotonNetwork.CurrentRoom.SetCustomProperties(roomProperty); // 현재 방의 커스텀 프로퍼티로 지정한다.
// SetCustomProperties()를 통해 커스텀 프로퍼티를 변경할 때마다,
// NetworkManager에서 콜백함수(OnRoomPropertiesUpdate)로 MapChange()를 수행하므로 더 작성할 것은 없다.
}
// 오른쪽 클릭도 비슷하게 작성하면 된다.
public void MapChange() // 맵변경에 사용 되는 함수
{
// 맵 인덱스에 커스텀 프로퍼티로 저장한 값을 가져온다. 맵 인덱스는 int이므로 형변환을 해준다
mapIndex = (int)PhotonNetwork.CurrentRoom.CustomProperties["Map"];
Debug.Log($"룸매니저 인덱스 변경됨 : {mapIndex}");
mapImage.sprite = mapSprites[mapIndex];
}
위와 같이 작성하고, 콜백 함수로 동기화를 한다.
위의 소스코드를 살펴보면, 좌,우 버튼을 누를 때 마다 해시테이블을 새로 만들어서 설정하는 것을 볼 수 있다.
PhotonNetwork.CurrentRoom.CustomProperties["Map"] = mapIndex;
해당 방법을 써 보려고 했지만, 커스텀 프로퍼티를 직접 수정하는것은 불가능하다. 커스텀 프로퍼티를 설정하려면 SetCustomProperties()를 써야만 한다. 그리고, SetCustomProperties()에는 변동사항만 적어서 보내야 하므로 그때그때 새로운 Hashtable을 생성해야 한다.
방을 생성할 때, 커스텀 프로퍼티를 어떤 것을 사용하는지 룸 옵션으로 설정하고 만든다. 콜백으로 방장이 방을 만들고 입장할 때, 방 커스텀 프로퍼티를 초기화 하고 들어간다.
public void CreateRoom() // 방 생성 버튼을 클릭 시 수행
{
if (string.IsNullOrEmpty(roomNameText.text)) // 방 이름 입력 확인
{
Debug.LogWarning("방 이름 입력이 없음");
return;
}
roomNameAdmitBtn.interactable = false; // 더블클릭 같은 문제를 미연에 방지함
// RoomOptions : 여러 사용할 수 있는 옵션들이 있다.
// 이번에는 최대 플레이어 설정만 해본다.
RoomOptions roomOptions = new RoomOptions{MaxPlayers = 8};
roomOptions.CustomRoomPropertiesForLobby = new string[]{ "Map" }; // RoomInfo에 포함될 RoomOptions에 커스텀 프로퍼티 연결
// CustomRoomPropertiesForLobby : string 배열로 초기화 한다. 해당 키를 사용한다고 명시한다.
PhotonNetwork.CreateRoom(roomNameText.text, roomOptions); // 최대 8명까지 접속 가능한 방으로 설정
roomNameText.text = null;
Debug.Log("방을 생성 시도");
}
public override void OnCreatedRoom() // 방 생성 시 호출
{
base.OnCreatedRoom();
Hashtable roomProperty = new(); // 방 커스텀 프로퍼티 초기화 작업 진행
roomProperty["Map"] = 0;
PhotonNetwork.CurrentRoom.SetCustomProperties(roomProperty);
lobbyPanel.SetActive(false);
Debug.Log("방을 만들기 완료. 방에 참가 시도");
}
public override void OnRoomPropertiesUpdate(Hashtable propertiesThatChanged) // 방의 커스텀 프로퍼티가 변경 될 때 수행
{
base.OnRoomPropertiesUpdate(propertiesThatChanged);
Debug.Log($"방 인덱스 변경됨 : {roomManager.mapIndex}");
roomManager.MapChange(); // 커스텀 프로퍼티는 모든 클라이언트가 공유 하므로, 자동으로 수행됨
}

방장이 설정한 대로 맵이 변경된다.

방장의 맵 설정에 따라 로비 창에서 맵 이름이 바뀌는 것을 알 수 있다.
roomOptions.CustomRoomPropertiesForLobby를 통해서 로비에 있어도 해당 방의 정보를 볼 수 있다.
모든 플레이어가 레디 상태여야지만 게임을 시작할 수 있게 구현해보자.
플레이어의 슬롯에 레디 버튼을 추가해서 레디 상태에 대한 커스텀 프로퍼티를 추가한다.
using Hashtable = ExitGames.Client.Photon.Hashtable;
public class PlayerSlot : MonoBehaviour
{
[Header("Set Slot References")]
[SerializeField] private TextMeshProUGUI nickNameText;
[SerializeField] private TextMeshProUGUI readyText;
[SerializeField] private Image hostImage;
[SerializeField] private Image readyBtnImage;
[SerializeField] private Button readyBtn;
private bool isReady;
public void Init(Player player) // 슬롯에 표시될 플레이어의 정보를 담는다.
{
isReady = false; // 방에 들어왔을 때는 false 디폴트
nickNameText.text = player.NickName;
hostImage.enabled = player.IsMasterClient; // 플레이어가 호스트면 활성화
readyBtn.interactable = player.IsLocal; // 플레이어가 호스트가 아니면 활성화
if (!player.IsLocal) return; // 본인 클라이언트만 설정하기 위한 return
ReadyPropertyUpdate();
readyBtn.onClick.RemoveAllListeners();
readyBtn.onClick.AddListener(ReadyButttonClick);
}
public void ReadyButttonClick()
{
isReady = !isReady; // 레디 상태 전환
readyText.text = isReady ? "Ready" : "Not Ready";
readyBtnImage.color = isReady ? Color.green : Color.grey; // 3항식으로 간단하게 표현
ReadyPropertyUpdate();
}
public void ReadyPropertyUpdate()
{
Hashtable playerProperty = new() { ["Ready"] = isReady }; // 커스텀 프로퍼티 설정. 생성자로 간단히 표현
PhotonNetwork.LocalPlayer.SetCustomProperties(playerProperty); // 각 클라이언트마다 서로 다른 레디 상태이므로, LocalPlayer로 설정
}
public void CheckReady(Player player) // 플레이어가 레디했는지 체크. NetworkManager의 OnPlayerPropertiesUpdate에 의해 수행된다.
{
if (player.CustomProperties.TryGetValue("Ready", out object playerReady))
{
readyText.text = (bool)playerReady ? "Ready" : ""; // 꺼낸 값을 3항식으로 간단화
readyBtnImage.color = (bool)playerReady ? Color.green : Color.grey;
}
}
}
플레이어의 커스텀 프로퍼티가 변경 될 때 호출되는 콜백함수를 이용한다.
public override void OnPlayerPropertiesUpdate(Player targetPlayer, Hashtable changedProps)
// 플레이어의 커스텀 프로퍼티가 변경될 때마다 수행되는 콜백함수.
{
base.OnPlayerPropertiesUpdate(targetPlayer, changedProps);
roomManager.playerDict[targetPlayer.ActorNumber].CheckReady(targetPlayer); // 커스텀 프로퍼티가 변경되었으니 레디 체크
}
방장이 방을 떠나거나, 방장 권한을 위임하는 기능을 구현해본다. 방장이 방을 떠날 경우, Master Client는 자동으로 변경된다.
마스터 클라이언트가 변경 되었을 때 수행하는 콜백함수 이용
public override void OnMasterClientSwitched(Player newMasterClient)
{
base.OnMasterClientSwitched(newMasterClient);
roomManager.PlayerPanelSpawn(newMasterClient); // 마스터 클라이언트 변경 시, 해당 클라이언트 플레이어 슬롯 재생성
}
마스터 클라이언트가 변경 될 때, Player 정보를 받아올 수 있으므로, 해당 Player의 Player Slot을 새로 생성한다.
public void PlayerPanelSpawn(Player player)
// Player : 플레이어와 관련한 정보들이 담겨있음. player 정보를 입력 시 생성
// NetworkManager의 OnMasterClientSwitched에서 수행된다
// return 이후로는 기존 플레이어가 아닐 경우이다.
{
if (playerDict.TryGetValue(player.ActorNumber, out PlayerSlot playerPanelItem))
{
startBtn.interactable = true;
mapLeftBtn.interactable = true;
mapRightBtn.interactable = true;
playerPanelItem.Init(player);
return;
}
GameObject obj = Instantiate(playerPanelItemPrefabs, playerPanelContent, true);
obj.GetComponent<PlayerSlot>().Init(player);
PlayerSlot item = obj.GetComponent<PlayerSlot>(); // 초기화
playerDict.Add(player.ActorNumber,item); // 방에 들어온 순서대로 ActorNumber가 올라감.
}
public void PlayerPanelDestroy(Player player) // 플레이어가 떠날 시 호출
{
if (playerDict.TryGetValue(player.ActorNumber, out PlayerSlot item))
{
Destroy(item.gameObject); // 플레이어가 떠났으니, 슬롯 삭제
playerDict.Remove(player.ActorNumber); // 딕셔너리에서도 제거
}
else
{
Debug.LogWarning($"플레이어가 딕셔너리에 없음: {player.ActorNumber}");
}
}

기존의 방장이 떠날 경우, 남은 플레이어에게 방장이 위임된다. 재입장해도 새로 방장이 변경되지 않음.
모든 플레이어가 준비됐을 경우, 게임 씬으로 같이 이동하게 해본다.
모든 플레이어가 레디를 했는지 체크한다. 레디가 완료 됐을 때, 마스터 클라이언트가 Start 버튼을 누르면 게임 씬으로 모든 클라이언트가 동시에 넘어간다.
빌드 세팅에 게임 씬을 추가해서 테스트 해보면 된다.
public void PlayerPanelSpawn()// 내가 새로 입장했을 때 호출. OnJoinedRoom()에서 수행됨.
{
PhotonNetwork.AutomaticallySyncScene = true; // 동기화. 마스터 클라이언트를 따라 동시에 같은 레벨을 로드함.
// PlayerPanelSpawn은 모든 플레이어가 방 입장 시 수행하기 때문에
// 해당 내용을 여기서 수행함.
foreach (Player player in PhotonNetwork.PlayerList) // 현재 방에 접속한 모든 플레이어(PlayerList)
{
if (!PhotonNetwork.IsMasterClient) // 본인이 호스트가 아닐 경우, 권한 뺏음
{
startBtn.interactable = false;
mapLeftBtn.interactable = false;
mapRightBtn.interactable = false;
MapChange();
}
// 플레이어 슬롯을 생성해서 패널에 추가함. 초기화 과정
GameObject obj = Instantiate(playerPanelItemPrefabs, playerPanelContent, true);
obj.GetComponent<PlayerSlot>().Init(player);
PlayerSlot item = obj.GetComponent<PlayerSlot>(); // 초기화
playerDict.Add(player.ActorNumber,item); // 방에 들어온 순서대로 ActorNumber가 올라감. 재입장해도 새로 배정 받음
}
}
public void GameStart() // 마스터 클라이언트가 Start 버튼을 누를 때 수행
{
if (PhotonNetwork.IsMasterClient&&AllPlayerReadyCheck()) // 마스터 클라이언트 이고, 모든 유저가 레디일 때
{
PhotonNetwork.LoadLevel("GameScene"); // 게임씬을 로드함. string으로 로드.
// 모든 방 구성원이 해당 함수를 수행하게 하려면
// PhotonNetwork.AutomaticallySyncScened 이 true로 되어 있어야 한다.
}
else if(!AllPlayerReadyCheck())
{
Debug.Log("모든 플레이어가 준비되지 않았습니다.");
}
}
public bool AllPlayerReadyCheck() // 모든 플레이어가 레디했는지 체크하는 함수. 위의 GameStart에서 호출
{
foreach (Player player in PhotonNetwork.PlayerList)
{
if (!player.CustomProperties.TryGetValue("Ready", out object value)||!(bool)value)
// Ready 커스텀 프로퍼티는 PlayerSlot의 ReadyPropertyUpdate에서 지정된다
{
return false; // 누군가 준비되지 않음
}
}
return true; // 모든 플레이어가 준비됨
}
게임 씬에서 쓰일 테스트 스크립트다.
public class TestNetworkManager : MonoBehaviourPunCallbacks
{
void Start()
{
// if (!PhotonNetwork.IsConnected) // 연결되지 않았을 경우
// {
PhotonNetwork.ConnectUsingSettings(); // Disconnected일 때만 수행한다.
// }
}
public override void OnConnectedToMaster()
{
PhotonNetwork.JoinRandomOrCreateRoom(); // 랜덤 방에 입장하거나, 새로운 방을 만들어 입장한다
//PhotonNetwork.JoinOrCreateRoom("Room Name",null,null);
}
public override void OnJoinedRoom()
{
Debug.Log("입장 완료");
PhotonNetwork.LocalPlayer.NickName = $"Player_{PhotonNetwork.LocalPlayer.ActorNumber}"; // 입장한 플레이어들 넘버로 닉네임 설정
}
public override void OnPlayerEnteredRoom(Player player)
{
Debug.Log($"{player.NickName} 입장 완료");
}
}
이제 게임 씬에서 플레이어들간의 동기화 작업을 해본다.
일단 게임씬에 모든 클라이언트들이 같이 참여했다면 플레이어 생성이 먼저 되어야 한다.

플레이어로 생성할 게임 오브젝트의 프리팹에 Photon View를 컴포넌트로 부착한다.

Ownership Transfer : 소유권 이전에 대한 설정이다. 기본적으로 생성 클라이언트에게 고정(Fixed)로 되어 있다.
Synchronization : 동기화. Unreliable은 안정적이지 않은 전달 방식이지만, 그만큼 빠르다. On Change는 변화가 있을 때만 전달하는 것이다. Reliable은 정확한 동기화가 필요할 때 사용한다.
Observable Search : 동기화 요소를 찾는 설정. active는 활성화 시에 수행된다. Manual은 직접 찾는 것이다.
게임 씬에서의 동기화 작업을 끝낼 때 까지 사용되는 테스트 스크립트. 동기화 작업이 끝나면 룸에서 연결되도록 다시 만든다.
public class TestNetworkManager : MonoBehaviourPunCallbacks
{
[SerializeField] private TextMeshProUGUI stateText;
void Start()
{
// if (!PhotonNetwork.IsConnected) // 연결되지 않았을 경우
// {
PhotonNetwork.ConnectUsingSettings(); // Disconnected일 때만 수행한다.
// }
}
void Update()
{
stateText.text = $"Current State : {PhotonNetwork.NetworkClientState}"; // 여러 필드들을 확인할 수 있음.
Debug.Log(PhotonNetwork.NetworkClientState);
}
public override void OnConnectedToMaster()
{
PhotonNetwork.JoinRandomOrCreateRoom(); // 랜덤 방에 입장하거나, 새로운 방을 만들어 입장한다
//PhotonNetwork.JoinOrCreateRoom("Room Name",null,null);
}
private void PlayerSpawn() // 플레이어 생성. OnJoinedRoom에서 수행
{
Vector3 spawnPos = new Vector3(Random.Range(0,5),1,Random.Range(0,5));
// PhotonNetwork의 Instantiate를 써야 다른 클라이언트도 동일하게 생성한다.
PhotonNetwork.Instantiate("Prefabs/Player/Player", spawnPos, Quaternion.identity);
}
public override void OnJoinedRoom()
{
Debug.Log("입장 완료");
PhotonNetwork.LocalPlayer.NickName = $"Player_{PhotonNetwork.LocalPlayer.ActorNumber}"; // 입장한 플레이어들 넘버로 닉네임 설정
PlayerSpawn(); // 방에 입장하면 플레이어 생성
}
public override void OnPlayerEnteredRoom(Player player)
{
Debug.Log($"{player.NickName} 입장 완료");
}
}
스크립트를 작성하고 플레이어 프리팹도 만들었다면, 프리팹은 Resources 폴더 안에 넣어야 한다. 그래야 함수에서 해당 프리팹을 찾아서 생성할 수 있다. 폴더 안에 넣을 것이라면, 폴더 경로까지 같이 적어줘야 한다.

이렇게 두 클라이언트 간에 생성이 동기화 된 것이 보인다.

PhotonView 컴포넌트로 생성된 같은 네트워크 객체는 어느 클라이언트에서든 동일한 View ID를 가진다. 닉네임으로 생각하면 편하다IsMine이 한쪽은 true, 한쪽은 false인 것을 볼 수 있다. 소유권은 생성한 클라이언트가 가지게 된다.이 상황에서, 한쪽 클라이언트가 접속을 종료하면 해당 클라이언트가 생성한 게임 오브젝트(네트워크 객체)는 파괴된다.
이전 포스트에서 다뤘듯이, 만약 해당 클라이언트가 접속 종료 시에도 객체 파괴를 원치 않으면, RoomObject로 생성해줘야 한다.
플레이어를 생성했으니, 이제 각 클라이언트마다 조작을 할 수 있게 해본다.
플레이어 프리팹에 부착되는 컴포넌트 스크립트.
using System.Collections;
using System.Collections.Generic;
using Photon.Pun;
using UnityEngine;
public class PlayerController : MonoBehaviourPun, IPunObservable
// 변수 동기화를 위해 인터페이스를 상속받는다
{
[SerializeField] private float moveSpeed=5;
[SerializeField] private float rotSpeed=30;
void Update()
{
if (photonView.IsMine)
// 해당 객체를 생성한 클라이언트만 움직일 수 있게 한정
{
Move();
}
}
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
// IPunObservable 인터페이스 상속 시 구현해야하는 함수
{
if (stream.IsWriting)
{
stream.SendNext(transform.position);
stream.SendNext(transform.rotation);
}
else if (stream.IsReading)
{
// ReceiveNext()는 object를 반환하기 때문에 형변환이 필요함
transform.position = (Vector3)stream.ReceiveNext();
transform.rotation = (Quaternion)stream.ReceiveNext();
}
}
private void Move()
{
float x = Input.GetAxis("Horizontal");
float z = Input.GetAxis("Vertical");
Vector3 moveDir = new Vector3(x, 0, z).normalized;
if (moveDir == Vector3.zero) return; // 입력이 없으면 return;
transform.Translate(moveDir*moveSpeed*Time.deltaTime,Space.World);
// Slerp : 구면으로 보간하는 기법이다. 3D 환경에서 쓰인다.
transform.rotation = Quaternion.Slerp(transform.rotation,
Quaternion.LookRotation(moveDir),
rotSpeed * Time.deltaTime);
}
}
실행해보면, 다른 클라이언트의 플레이어 움직임이 매우 부자연스러운 것을 볼 수 있다. 이는 transform.position, transform.rotation이 매 프레임마다 넘겨받는 것이 아니다 보니, 딱딱 끊어지게 보이는 것이다. 부드럽게 하기 위해서는 지연 보상을 통해 해결한다.
전달받은 위치, 각도를 한 번 캐싱한 다음, 이전 값과 비교해서 차이만큼 보간 작업을 해주면 된다.
// 네트워크에서 전송받은 위치
private Vector3 networkPosition;
private Quaternion networkRotation;
// 네트워크 위치와 현재 위치 차이
private float deltaPosition;
private float deltaRotation;
// 보간 수치
private float interpolatePos;
private float interpolateRot;
void Update()
{
if (photonView.IsMine) // 해당 객체를 생성한 클라이언트만 움직일 수 있게 한정
{
Move();
}
else
{
MoveOther(); // 소유권이 없는 다른 플레이어들의 이동
}
}
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
// IPunObservable 인터페이스 상속 시 구현해야하는 함수
{
if (stream.IsWriting)
{
stream.SendNext(transform.position);
stream.SendNext(transform.rotation);
}
else if (stream.IsReading)
{
// ReceiveNext()는 object를 반환하기 때문에 형변환이 필요함
networkPosition = (Vector3)stream.ReceiveNext();
networkRotation = (Quaternion)stream.ReceiveNext();
// 현재 값과 네트워크에서 받은 값의 차이를 구한다
deltaPosition = Vector3.Distance(transform.position,networkPosition);
deltaRotation = Quaternion.Angle(transform.rotation, networkRotation);
}
}
private void MoveOther() // 소유권이 없는 플레이어들 이동
{
// 보간수치 초기화
interpolatePos = deltaPosition * Time.deltaTime * PhotonNetwork.SerializationRate;
interpolateRot = deltaRotation * Time.deltaTime * PhotonNetwork.SerializationRate;
transform.position = Vector3.MoveTowards(transform.position, networkPosition,interpolatePos );
transform.rotation = Quaternion.RotateTowards(transform.rotation, networkRotation, interpolateRot);
}
이제 플레이어에 애니메이션을 적용 해본다. 모델과 애니메이션 에셋을 구한 후, 적용 해본다.
간단하게 Animator의 FSM을 Idle과 Run 두개만 해본다. 만들었다면, 스크립트를 수정한다.
// 애니메이션
private Animator animator;
private bool isRun;
private void Start()
{
animator = GetComponent<Animator>();
}
private void Update()
{
animator.SetBool(IsRun,isRun);
if (photonView.IsMine) // 해당 객체를 생성한 클라이언트만 움직일 수 있게 한정
Move();
else
MoveOther(); // 소유권이 없는 다른 플레이어들의 이동
}
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
// IPunObservable 인터페이스 상속 시 구현해야하는 함수
{
if (stream.IsWriting)
{
stream.SendNext(transform.position);
stream.SendNext(transform.rotation);
stream.SendNext(isRun);
}
else if (stream.IsReading)
{
// ReceiveNext()는 object를 반환하기 때문에 형변환이 필요함
networkPosition = (Vector3)stream.ReceiveNext();
networkRotation = (Quaternion)stream.ReceiveNext();
isRun = (bool)stream.ReceiveNext();
deltaPosition = Vector3.Distance(transform.position, networkPosition);
deltaRotation = Quaternion.Angle(transform.rotation, networkRotation);
}
}
private void Move()
{
var x = Input.GetAxis("Horizontal");
var z = Input.GetAxis("Vertical");
var moveDir = new Vector3(x, 0, z).normalized;
if (moveDir == Vector3.zero)
{
isRun = false;
return; // 입력이 없으면 return;
}
isRun = true;
transform.Translate(moveDir * (moveSpeed * Time.deltaTime), Space.World);
transform.rotation = Quaternion.Slerp(transform.rotation, // Slerp : 구면으로 보간하는 기법이다. 3D 환경에서 쓰인다.
Quaternion.LookRotation(moveDir),
rotSpeed * Time.deltaTime);
}

깔끔하게 동기화가 되었다.

이번에는 직접 구현했지만, Transform과 Animation의 경우 위와 같은 컴포넌트들로 간단히 동기화 시킬 수 있다. 그래도 직접 구현하는 것이 더 다양한 커스텀이 가능하다. 스크립트를 참고만 하자.
동일한 프리팹으로 생성된 플레이어들 중, 원하는 객체를 지정하려면 ViewID를 통해서 찾으면 된다.
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
if (stream.IsWriting)
{
stream.SendNext(photonView.ViewID);
}
else if (stream.IsReading)
{
int id = (int)stream.ReceiveNext();
PhotonView pv = PhotonView.Find(id); // ID를 기준으로, PhotonView 객체를 찾을 수 있다.
pv.GetComponent<Collider>(); // PhotonView로 바로 GetComponent를 찾을 수 있다.
isRun = (bool)stream.ReceiveNext();
}
}
이 방법 말고, RPC(함수동기화)를 통해 찾는 것 또한 방법이다.
이번에는 플레이어의 공격을 동기화 해보자. RPC를 사용한다.
마우스 왼클릭 시 공격이 되게 해본다.
private void Update()
{
animator.SetBool(IsRun,isRun);
if (photonView.IsMine) // 해당 객체를 생성한 클라이언트만 움직일 수 있게 한정
{
Move();
if (Input.GetKeyDown(KeyCode.Mouse0))
{
photonView.RPC("RPC_Attack",RpcTarget.AllViaServer,PhotonNetwork.LocalPlayer.ActorNumber);
}
}
else
MoveOther(); // 소유권이 없는 다른 플레이어들의 이동
}
[PunRPC]
private void RPC_Attack(int num,PhotonMessageInfo info)
{
//info.photonView 포톤뷰
//info.Sender 플레이어
//info.SentServerTime 보낸 서버시간
BulletModel bullet = Instantiate(bulletPrefab,muzzlePoint.position,muzzlePoint.rotation).GetComponent<BulletModel>();
bullet.rig.AddForce(muzzlePoint.forward * bullet.bulletSpeed,ForceMode.Impulse);
}
public class BulletModel : MonoBehaviour
{
public Rigidbody rig;
[SerializeField] public float bulletSpeed;
void OnEnable()
{
rig = GetComponent<Rigidbody>();
Destroy(gameObject, 5);
}
void OnCollisionEnter(Collision collision)
{
Destroy(gameObject,3);
}
}

위와 같이 총알이 중구난방 나가서 서로 달라진다. 네트워크 지연 시간을 이용해서 차이를 줄여본다
PlayerController 스크립트를 수정한다.
[PunRPC]
private void RPC_Attack(int num,PhotonMessageInfo info)
{
// 지연시간 계산. Abs는 음수가 나오는 경우를 대비
float lag = Mathf.Abs((float)PhotonNetwork.Time - (float)info.SentServerTime);
BulletModel bullet = Instantiate(bulletPrefab,muzzlePoint.position,muzzlePoint.rotation).GetComponent<BulletModel>();
bullet.ApplyLagCompensation(lag);
}
BulletModel 스크립트 또한 수정한다.
public void ApplyLagCompensation(float lag) // 지연 시간을 이용한 지연 보상
{
rig.velocity = transform.forward * bulletSpeed;
rig.position += rig.velocity * lag;
}

거의 완벽하게 총알이 동일하게 나간다.
만약, 좀 더 확실하게 하고 싶다면 직접 transform과 rigidbody를 전송하도록 하자. 지금은 발사되는 순간의 muzzlePoint의 forward가 엇나가는 부분이 있다보니 이런 것이다. 또는 총알을 생성할 때, PhotonNetwork.Instantiate를 사용해보자.
기존의 방장이 방을 나갈 시, 방장이 생성한 네트워크 객체는 사라지는 문제가 있다. RoomObject 생성을 하는 걸로 이 문제를 해결한다.
게임 씬에 입장 시, 일정 시간마다 몬스터를 랜덤위치에 만들어 본다. TestNetworkManager 스크립트 수정.
public override void OnMasterClientSwitched(Player newMasterClient)
// 마스터가 변경됐을 시(방장이 나갔을 때)
{
if (newMasterClient == PhotonNetwork.LocalPlayer)
{
StartCoroutine(MonsterSpawn());
}
}
public override void OnJoinedRoom()
{
Debug.Log("입장 완료");
PhotonNetwork.LocalPlayer.NickName = $"Player_{PhotonNetwork.LocalPlayer.ActorNumber}"; // 입장한 플레이어들 넘버로 닉네임 설정
PlayerSpawn(); // 방에 입장하면 플레이어 생성
if (PhotonNetwork.IsMasterClient)
{
StartCoroutine(MonsterSpawn()); // 몬스터 생성 시작
}
}
private IEnumerator MonsterSpawn()
{
while (true)
{
yield return new WaitForSeconds(2f);
Vector3 spawnPos = new Vector3(Random.Range(0,5),1,Random.Range(0,5));
PhotonNetwork.InstantiateRoomObject("Prefabs/Monster/Monster1", spawnPos, Quaternion.identity);
}
}

이와 같이 작성하면, 방장이 나가도 기존의 몬스터(네트워크 객체)가 사라지지 않고, 생산은 계속된다.
룸에서 사용하는 채팅 시스템을 구현해본다.

위와 같이 UI를 구성.


룸에서의 채팅을 관리하는 채팅 매니저 스크립트. 채팅 프리팹은 텍스트 UI 하나를 ScrollView의 Content안에 넣어서 만들어 준다.
using System.Collections;
using System.Collections.Generic;
using Photon.Pun;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class ChatManager : MonoBehaviourPun
{
[SerializeField] private TMP_InputField chatField;
[SerializeField] private ScrollRect scrollRect;
[SerializeField] private TextMeshProUGUI chatTextPrefab;
public Transform chatContent;
void Start()
{
chatField.onEndEdit.AddListener(HandleInput);
}
private void HandleInput(string text)
{
if (!Input.GetKeyDown(KeyCode.Return)) //엔터키로 EndEdit이 된게 아니면, 돌아감
{
return;
}
if (!string.IsNullOrWhiteSpace(text))
{
photonView.RPC("SentMessage", RpcTarget.All,PhotonNetwork.LocalPlayer.NickName, text);
chatField.text = "";
chatField.ActivateInputField(); // 인풋 필드를 다시 활성화
}
}
[PunRPC]
private void SentMessage(string sender, string message)
{
TextMeshProUGUI chatLog = Instantiate(chatTextPrefab, chatContent);
if (sender == PhotonNetwork.LocalPlayer.NickName)
{
chatLog.text = $"<color=green>{sender}</color> : {message}";
}
else
{
chatLog.text = $"{sender} : {message}";
}
Canvas.ForceUpdateCanvases(); // 게임 내 모든 칸바스 강제 업데이트
scrollRect.verticalNormalizedPosition = 0f;
}
}
방을 퇴장할 시, Content 안에 있는 챗 로그들을 삭제한다.
public override void OnLeftRoom() // 본인이 방 퇴장시 호출됨
{
base.OnLeftRoom();
foreach (Transform child in chatManager.chatContent)
{
Destroy(child.gameObject);
}
}
