XR플밍 - 12. UnityEngine3D 네트워크 프로그래밍 - CustomProperty

이형원·2025년 7월 14일
0

XR플밍

목록 보기
134/215

1. 포톤 구현 컨셉

지난 시간에 배웠던 내용에 대한 복습을 하고선 오늘 배운 내용에 대해 다뤄보고자 한다.

PhotonNetwork.ConnectUsingSettings();   // 접속 시도 요청
PhotonNetwork.Disconnect();             // 접속 해제 요청

PhotonNetwork.CreateRoom("RoomName");   // 방 생성 요청
PhotonNetwork.JoinRoom("RoomName");     // 방 입장 요청
PhotonNetwork.LeaveRoom();              // 방 퇴장 요청

PhotonNetwork.JoinLobby();              // 로비 입장 요청
PhotonNetwork.LeaveLobby();             // 로비 퇴장 요청

PhotonNetwork.LoadLevel("SceneName");   // 씬 전환 요청

bool isConnected = PhotonNetwork.IsConnected;           // 접속 여부 확인
bool isInRoom = PhotonNetwork.InRoom;                   // 방 입장 여부 확인
bool isLobby = PhotonNetwork.InLobby;                   // 로비 입장 여부 확인
ClientState state = PhotonNetwork.NetworkClientState;   // 클라이언트 상태 확인
Player player = PhotonNetwork.LocalPlayer;              // 포톤 플레이어 정보 확인
Room players = PhotonNetwork.CurrentRoom;               // 현재 방 정보 확인
  • 이 콜백함수에 원하는 반응을 구현하여 서버측에서 보내는 반응에 대응하는 기능을 구현할 수 있다.
public class NetworkManager : MonoBehaviourPunCallbacks
{
  public override void OnConnected() { }                          // 포톤 접속시 호출됨
  public override void OnConnectedToMaster() { }                  // 마스터 서버 접속시 호출됨
  public override void OnDisconnected(DisconnectCause cause) { }  // 접속 해제시 호출됨

  public override void OnCreatedRoom() { }    // 방 생성시 호출됨
  public override void OnJoinedRoom() { }     // 방 입장시 호출됨
  public override void OnLeftRoom() { }       // 방 퇴장시 호출됨
  public override void OnPlayerEnteredRoom(Player newPlayer) { }  // 새로운 플레이어가 방 입장시 호출됨
  public override void OnPlayerLeftRoom(Player otherPlayer) { }   // 다른 플레이어가 방 퇴장시 호출됨
  public override void OnCreateRoomFailed(short returnCode, string message) { }   // 방 생성 실패시 호출됨
  public override void OnJoinRoomFailed(short returnCode, string message) { }     // 방 입장 실패시 호출됨

  public override void OnJoinedLobby() { }    // 로비 입장시 호출됨
  public override void OnLeftLobby() { }      // 로비 퇴장시 호출됨
  public override void OnRoomListUpdate(List<RoomInfo> roomList) { }  // 방 목록 변경시 호출됨
}

2. 커스텀 프로퍼티

포톤에서 방과 플레이어 클래스는 게임에 활용하기 위한 여러 정보를 포함할 수 있다.
예를 들어 방에는 이름 & 최대 참여가능 인원 & 현재 참여인원 & 공개여부 등이 있으며, 플레이어에는 닉네임 & 아이디 & 방장여부 등이 있다.

하지만, 포톤에서 제공하는 정보들만으로 게임을 구성하기에는 한계가 있으므로 추가적인 정보에 관해 커스텀 프로퍼티를 사용할 수 있다.
커스텀 프로퍼티로 추가하고자 하는 정보의 이름과 값을 설정하여 같은 게임을 플레이하는 구성원들에게 정보를 공유하고 동기화 할 수 있다.

아래와 같은 사용 방식으로 커스텀 프로퍼티를 사용해보자.

2.1 룸에서의 맵 변경 기능 구현하기

게임 대기실에서 방장이 맵을 선택하고 바꾸는 등의 기능을 본 적 있을 것이다. 이 부분을 한 번 구현해 보도록 하자.

이미지는 적당히 무료 이미지 3종 정도를 가져와서 사용하려고 한다.

이와 같은 이미지를 배열 형태로 넣어서 변경할 수 있도록 하고, RoomManager에서 맵 변경과 관련된 부분을 추가해보도록 하자.

using Photon.Pun;
using Photon.Realtime;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class RoomManager : MonoBehaviour
{
    [SerializeField] private Button startButton;
    [SerializeField] private Button leaveButton;

    [SerializeField] private Button mapLeftButton;
    [SerializeField] private Button mapRightButton;
    [SerializeField] private Image mapImage;
    [SerializeField] private Sprite[] mapSprites;
    public int mapIndex;

    [SerializeField] private GameObject playerPanelItemPrefabs;
    [SerializeField] private Transform playerPanelContent;

    public Dictionary<int, PlayerPanelItem> playerPanels = new Dictionary<int, PlayerPanelItem>();

    private void Start()
    {
        startButton.onClick.AddListener(GameStart);
        leaveButton.onClick.AddListener(LeaveRoom);
        mapLeftButton.onClick.AddListener(ClickLeftMapButton);
        mapRightButton.onClick.AddListener(ClickRightButton);
    }

    /// <summary>
    /// 기존 플레이어가 새로은 플레이어 입장시 호출
    /// </summary>
    /// <param name="player"></param>
    public void PlayerPanelSpawn(Player player)
    {
        if(playerPanels.TryGetValue(player.ActorNumber, out PlayerPanelItem panel))
        {
            startButton.interactable = true;
            mapLeftButton.interactable = true;
            mapRightButton.interactable = true;
            panel.Init(player);
            return;
        }

        GameObject obj = Instantiate(playerPanelItemPrefabs);
        obj.transform.SetParent(playerPanelContent);
        PlayerPanelItem item = obj.GetComponent<PlayerPanelItem>();
        item.Init(player);
        playerPanels.Add(player.ActorNumber, item);
    }

    /// <summary>
    /// 내가 새로 입장했을 때
    /// </summary>
    public void PlayerPanelSpawn()
    {
        PhotonNetwork.AutomaticallySyncScene = true;

        if (!PhotonNetwork.IsMasterClient)
        {
            startButton.interactable = false;
            mapLeftButton.interactable = false;
            mapRightButton.interactable = false;
            MapChange();
        }

        foreach (Player player in PhotonNetwork.PlayerList)
        {
            GameObject obj = Instantiate(playerPanelItemPrefabs);
            obj.transform.SetParent(playerPanelContent);
            PlayerPanelItem item = obj.GetComponent<PlayerPanelItem>();
            item.Init(player);
            playerPanels.Add(player.ActorNumber, item);
        }
    }

	...

    public void ClickLeftMapButton()
    {
        mapIndex--;
        if (mapIndex == -1) mapIndex = mapSprites.Length - 1;

        ExitGames.Client.Photon.Hashtable roomProperty = new ExitGames.Client.Photon.Hashtable();
        roomProperty["Map"] = mapIndex;
        PhotonNetwork.CurrentRoom.SetCustomProperties(roomProperty);

        MapChange();
    }

    public void ClickRightButton()
    {
        mapIndex++;
        if (mapIndex == mapSprites.Length) mapIndex = 0;

        ExitGames.Client.Photon.Hashtable roomProperty = new ExitGames.Client.Photon.Hashtable();
        roomProperty["Map"] = mapIndex;
        PhotonNetwork.CurrentRoom.SetCustomProperties(roomProperty);

        MapChange();
    }

    public void MapChange()
    {
        mapIndex = (int)PhotonNetwork.CurrentRoom.CustomProperties["Map"];
        Debug.Log(mapIndex);
        mapImage.sprite = mapSprites[mapIndex];
    }
}

여기서 크게 추가된 부분이라고 한다면, MapButton에 대한 함수가 추가된 것이라고 할 수 있을 것이다. 해당 기능의 매커니즘 자체는 그다지 어렵지 않지만 여기서 주목해야 할 점은 바로 커스텀 프로퍼티를 설정하는 부분이다.

  • 커스텀 프로퍼티의 설정 - map
ExitGames.Client.Photon.Hashtable roomProperty = new ExitGames.Client.Photon.Hashtable();
roomProperty["Map"] = mapIndex;
PhotonNetwork.CurrentRoom.SetCustomProperties(roomProperty);

이와 같이 단순하게 Map의 이미지만 반영해서 UI 상으로 띄우는 것뿐만 아니라, PhotonNetwork 상에 해당 변수를 저장할 수 있도록 해야 한다.

이러한 커스텀 프로퍼티의 설정은 네트워크 매니저에게까지 전달을 해야하며, 이와 같이 전달한다.

  • NetworkManager
	...

    public override void OnRoomPropertiesUpdate(ExitGames.Client.Photon.Hashtable propertiesThatChanged)
    {
        roomManager.MapChange();
    }
    
    ...

2.2 방장이 나갔을 때 방장 권한 위임하기

마찬가지로 방장이었던 사람이 나가도 남은 사람에게 방장 권한이 넘어가는 방법을 구현하고자 한다.

RoomManager에 내용을 추가하자.

...

/// <summary>
/// 기존 플레이어가 새로은 플레이어 입장시 호출
/// </summary>
/// <param name="player"></param>
public void PlayerPanelSpawn(Player player)
{
	// 만약 플레이어가 마스터 권한(방장)일 경우
    if(playerPanels.TryGetValue(player.ActorNumber, out PlayerPanelItem panel))
    {
        startButton.interactable = true;
        mapLeftButton.interactable = true;
        mapRightButton.interactable = true;
        panel.Init(player);
        return;
    }

    GameObject obj = Instantiate(playerPanelItemPrefabs);
    obj.transform.SetParent(playerPanelContent);
    PlayerPanelItem item = obj.GetComponent<PlayerPanelItem>();
    item.Init(player);
    playerPanels.Add(player.ActorNumber, item);
}

...

그리고 이걸 네트워크 매니저에서 반영할 수 있도록 한다.

...

public override void OnMasterClientSwitched(Player newClientPlayer)
{
    roomManager.PlayerPanelSpawn(newClientPlayer);
}

...

2.3 플레이어가 모두 레디했을 때 게임 시작하기

이제 마지막으로 모든 플레이어의 레디 여부를 확인하고, 모든 플레이어가 레디를 하면 게임을 실행시킬 수 있도록 해 보자.

RoomManager에 필요한 것은, 모든 플레이가 레디를 했는지 판별하는 함수와 게임을 실행하는 함수이다.
커스텀 프로퍼티로 플레이어에 대한 프로퍼티를 추가한다.

...

public void GameStart()
{
	// 게임 시작을 시도하는 유저가 방장이며, 모든 플레이어가 레디를 했을 경우
    if(PhotonNetwork.IsMasterClient && AllPlayerReadyCheck()) PhotonNetwork.LoadLevel("GameStart");
}

public bool AllPlayerReadyCheck()
{
    foreach(Player player in PhotonNetwork.PlayerList)
    {
    	// 플레이어의 레디 여부를 가져오지 못할 경우, 혹은 레디를 안 한 플레이어가 있을 경우
        if (!player.CustomProperties.TryGetValue("Ready", out object value) || !(bool)value)
        {
            return false;
        }
    }
    return true;
}

...

플레이어 프로퍼티가 업데이트 되었을 때 해당 네용이 업데이트 될 수 있도록 한다.

    public override void OnPlayerPropertiesUpdate(Player target, ExitGames.Client.Photon.Hashtable propertiesThatChanged)
    {
        roomManager.playerPanels[target.ActorNumber].ReadyCheck(target);
    }

3. 동기화

이제 게임 시작 직전까지의 상황을 모두 세팅했으니, 게임 시작 이후의 상황을 만들어보자.

3.1 포톤 뷰 (Photon View)

멀티 플레이어 게임에서는 특정 게임오브젝트를 동기화할 필요가 있다. 하지만 모든 오브젝트를 동기화하면 네트워크 통신량이 많이 필요하게 될 테니, 동기화가 필요한 오브젝트만을 관리하도록 포톤 뷰를 사용한다.

이와 같이 Photon View 라는 컴포넌트를 추가하면 해당 오브젝트는 동기화를 위한 기준이 된다. 이러한 포톤 뷰를 포함하는 게임오브젝트의 스크립트는 포톤 뷰를 참조하기 위한 변수를 가진 MonoBehaviourPun 클래스를 상속하여 만드는 것이 좋다.
우리는 위 사진과 같이 플레이어에게 MonoBehaviourPun을 상속하여 만들 것이다.

테스트를 위해 이와 같은 테스트씬으로 게임을 만든다고 해 보자. 게임은 TPS 멀티 플레이어 게임을 만든다는 가정 하에 간단하게 플레이어의 움직임과 탄환의 발사를 만들어보자.

  • PlayerController
using UnityEngine;
using Photon.Pun;
using UnityEditor.ShaderKeywordFilter;

public class PlayerController : MonoBehaviourPun, IPunObservable
{
    [SerializeField] private float moveSpeed;
    [SerializeField] private float rotSpeed;
    private Vector3 networkPosition;
    private Quaternion networkRotation;

    [SerializeField] private GameObject bulletPrefab;
    [SerializeField] private Transform muzzlePoint;

    [SerializeField] private float mouseSensitivity;

    public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
    {
        if(stream.IsWriting)
        {
            stream.SendNext(transform.position);
            stream.SendNext(transform.rotation);
            stream.SendNext(moveSpeed);
        }
        else
        {
            networkPosition = (Vector3)stream.ReceiveNext();
            networkRotation = (Quaternion)stream.ReceiveNext();
            moveSpeed = (float)stream.ReceiveNext();            
        }
    }


    private void Update()
    {
        if (photonView.IsMine)
        {
            Move();

            if(Input.GetMouseButtonDown(0))
            {
                photonView.RPC("Fire", RpcTarget.AllViaServer);
            }
        }
    }

    [PunRPC]
    private void Fire(PhotonMessageInfo info)
    {
        float lag = (float)PhotonNetwork.Time - (float)info.SentServerTime;
        GameObject newBullet = Instantiate(bulletPrefab,muzzlePoint.position, muzzlePoint.rotation);
        newBullet.GetComponent<Bullet>().ApplyLagCompensation(lag);
    }

    private void Move()
    {
        float x = Input.GetAxis("Horizontal");
        float z = Input.GetAxis("Vertical");

        Vector3 moveDir = (gameObject.transform.right * x + gameObject.transform.forward * z).normalized;
        transform.position += moveDir * moveSpeed * Time.deltaTime;

        if(moveDir != Vector3.zero)
        {
            transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(moveDir), rotSpeed * Time.deltaTime);
        }
    }
}

3.2 변수 동기화

변수 동기화는 게임 오브젝트를 동기화하는 첫 단계이다. 위의 플레이어 컨트롤러 스크립트에도 적용되어 있으며, IPunObservable 인터페이스를 붙이는 것으로 동기화가 진행된다.

여기서 반드시 데이터를 보낸 순서대로 받아서 사용해야 한다는 유의점이 있다.

public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
    if(stream.IsWriting)
    {
        stream.SendNext(transform.position);
        stream.SendNext(transform.rotation);
        stream.SendNext(moveSpeed);
    }
    else
    {
        networkPosition = (Vector3)stream.ReceiveNext();
        networkRotation = (Quaternion)stream.ReceiveNext();
        moveSpeed = (float)stream.ReceiveNext();            
    }
}

3.3 함수 동기화

게임 오브젝트에서의 동기화를 위한 동일한 함수 호출 방식이다. 포톤에서는 원격 함수 호출(RPC - Remote Procedure Call)를 사용한다.

private void Update()
    {
        if (photonView.IsMine)
        {
            Move();

            if(Input.GetMouseButtonDown(0))
            {
            	// 함수를 호출
                photonView.RPC("Fire", RpcTarget.AllViaServer);
            }
        }
    }

	// 호출할 함수를 RPC로 설정
    [PunRPC]
    private void Fire(PhotonMessageInfo info)
    {
        float lag = (float)PhotonNetwork.Time - (float)info.SentServerTime;
        GameObject newBullet = Instantiate(bulletPrefab,muzzlePoint.position, muzzlePoint.rotation);
        newBullet.GetComponent<Bullet>().ApplyLagCompensation(lag);
    }
  • 매개변수
    RPC 함수의 경우 여러 개의 매개변수를 가질 수 있으며 RPC 호출 시 동일한 순서로 매개변수를 전달한다.
    추가적으로 매개변수로 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); // 보낸 서버 시간
   }
}
  • 타겟과 버퍼
    RPC 함수에서 원하는 클라이언트에게만 전달을 진행하기 위해서 RpcTarget를 선정할 수 있다. 이를 이용하여 모든 클라이언트에게 전달하거나, 방장에게만 전달하거나, 이후 참가한 클라이언트에게 전달하거나, 등의 활동이 가능하다.

  • 소유자와 동작제한
    소유자는 포톤 뷰의 소유권을 가지고 있는 클라이언트이다. 모든 클라이언트는 소유자의 네트워크 객체 데이터를 기준으로 동기화를 진행하므로 소유자가 아닌 클라이언트의 네트워크 객체 조작은 무시한다.
    따라서, 포톤 뷰의 소유권이 없는 경우는 동작하지 않도록 소스코드를 구성할 필요가 있다.

if (photonView.IsMine)
{
    Move();

    if(Input.GetMouseButtonDown(0))
    {
        photonView.RPC("Fire", RpcTarget.AllViaServer);
    }
}

3.4 지연보상

멀티 플레이어 게임은 네트워크를 통한 데이터 전송에 따른 지연시간이 필연적으로 발생한다. 이런 문제를 완전히 해결하는 것은 불가능하므로, 발생하는 지연시간을 줄이도록 하는 방법을 지연 보상이라고 한다.

  • PlayerController
using UnityEngine;
using Photon.Pun;

public class PlayerController : MonoBehaviourPun, IPunObservable
{
    [SerializeField] private float moveSpeed;
    [SerializeField] private float rotSpeed;
    private Vector3 networkPosition;
    private Quaternion networkRotation;

    [SerializeField] private GameObject bulletPrefab;
    [SerializeField] private Transform muzzlePoint;

    [SerializeField] private float mouseSensitivity;

    public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
    {
        if(stream.IsWriting)
        {
            stream.SendNext(transform.position);
            stream.SendNext(transform.rotation);
            stream.SendNext(moveSpeed);
        }
        else
        {
            networkPosition = (Vector3)stream.ReceiveNext();
            networkRotation = (Quaternion)stream.ReceiveNext();
            moveSpeed = (float)stream.ReceiveNext();            
        }
    }


    private void Update()
    {
        if (photonView.IsMine)
        {
            Move();

            if(Input.GetMouseButtonDown(0))
            {
                photonView.RPC("Fire", RpcTarget.AllViaServer);
            }
        }
        else
        {
            transform.position = Vector3.Lerp(transform.position, networkPosition, Time.deltaTime);
            transform.rotation = Quaternion.Lerp(transform.rotation, networkRotation, Time.deltaTime);
        }
    }

    [PunRPC]
    private void Fire(PhotonMessageInfo info)
    {
    	// 지연시간 계산
        float lag = (float)PhotonNetwork.Time - (float)info.SentServerTime;
        GameObject newBullet = Instantiate(bulletPrefab,muzzlePoint.position, muzzlePoint.rotation);
        newBullet.GetComponent<Bullet>().ApplyLagCompensation(lag);
    }

    private void Move()
    {
        float x = Input.GetAxis("Horizontal");
        float z = Input.GetAxis("Vertical");

        Vector3 moveDir = (gameObject.transform.right * x + gameObject.transform.forward * z).normalized;
        transform.position += moveDir * moveSpeed * Time.deltaTime;

        Rotate();

        if(moveDir != Vector3.zero)
        {
            transform.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(moveDir), rotSpeed * Time.deltaTime);
        }
    }
    
    private void Rotate()
    {
        float mouseX = Input.GetAxis("Mouse X") * mouseSensitivity;
        float mouseY = -Input.GetAxis("Mouse Y") * mouseSensitivity;
        Vector2 mousePos = new Vector2(mouseX, mouseY);
        Debug.Log(mousePos);        
    }
}
  • Bullet
using UnityEngine;

public class Bullet : MonoBehaviour
{
    [SerializeField] private Rigidbody rigid;
    [SerializeField] private float bulletSpeed;

    private void Start()
    {
        rigid.AddForce(transform.forward * bulletSpeed, ForceMode.Impulse);
        Destroy(gameObject, 3f);
    }

	// 지연시간 보정
    public void ApplyLagCompensation(float lag)
    {
        rigid.velocity = transform.forward * bulletSpeed;
        rigid.position += rigid.velocity * lag;
    }
}
  • TestNetworkManager
using Photon.Pun;
using Photon.Realtime;
using System.Collections;
using UnityEngine;

public class TestNetworkManager : MonoBehaviourPunCallbacks
{
    void Start()
    {
        PhotonNetwork.Disconnect();
        PhotonNetwork.ConnectUsingSettings();
    }

    public override void OnConnectedToMaster()
    {
        PhotonNetwork.JoinRandomOrCreateRoom();
    }

    public override void OnCreatedRoom()
    {
        StartCoroutine(MosterSpawn());
    }

    public override void OnJoinedRoom()
    {
        Debug.Log("입장 완료");
        Debug.Log(PhotonNetwork.LocalPlayer.NickName);
        
        PhotonNetwork.LocalPlayer.NickName = $"layer_{PhotonNetwork.LocalPlayer.ActorNumber}";
        if(PhotonNetwork.IsMasterClient) PlayerSpawn();
    }

    public override void OnMasterClientSwitched(Player newMasterClient)
    {
        if(newMasterClient == PhotonNetwork.LocalPlayer)
        {
            StartCoroutine(MosterSpawn());
        }
    }

    private IEnumerator MosterSpawn()
    {
        while (true)
        {
            yield return new WaitForSeconds(3f);
            Vector3 spawnPos = new Vector3(Random.Range(0,5), 1, Random.Range(0,5));
            PhotonNetwork.Instantiate("Monster", spawnPos, Quaternion.identity);
        }
    }

    private void PlayerSpawn()
    {
        Vector3 spawnPos = new Vector3(Random.Range(0, 5), 1, Random.Range(0, 5));
        PhotonNetwork.Instantiate("Player", spawnPos, Quaternion.identity);
    }

    public override void OnPlayerEnteredRoom(Player player)
    {
        Debug.Log($"{player.NickName} 입장");
    }
}
profile
게임 만들러 코딩 공부중

0개의 댓글