XR플밍 - 12. UnityEngine3D 네트워크 프로그래밍 - 네트워크 게임 실습 - TPS 게임 간단 구현

이형원·2025년 7월 15일
0

XR플밍

목록 보기
135/215
post-thumbnail

1. 전체적인 코드 정리

우선은 이전까지 배웠던 내용까지 통틀어 전체적인 코드 정리를 하고, 아주 간단하게 인게임 씬까지 구현을 했다. 코드는 크게 대기맵까지의 코드와 인게임 씬의 코드로 분류를 했다.

1.1 로딩, 로비, 방 UI

  • NetworkManager

전체적인 네트워크의 관리 및 로비 입장, 방 입장 등의 상황에 대한 반응, 프로퍼티의 변동 전달 등의 역할을 한다.

using ExitGames.Client.Photon;
using Photon.Pun;
using Photon.Realtime;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class NetworkManager : MonoBehaviourPunCallbacks
{
    [Header("로딩 관련")]
    [SerializeField] private GameObject loadingPanel;
    [SerializeField] private TextMeshProUGUI stateText;

    [Header("닉네임 관련")]
    [SerializeField] private GameObject nicknamePanel;
    [SerializeField] private TMP_InputField nicknameField;
    [SerializeField] private Button nicknameAdmitButton;

    [Header("로비 관련")]
    [SerializeField] private GameObject lobbyPanel;
    [SerializeField] private TMP_InputField roomNameField;
    [SerializeField] private Button roomNameAdmitButon;

    [SerializeField] private Transform roomListContent;
    [SerializeField] private GameObject roomListItemPrefab;

    [Header("매니저")]
    [SerializeField] RoomManager roomManager;
    [SerializeField] ChatManager chatManager;

    private Dictionary<string, GameObject> roomListItems = new Dictionary<string, GameObject>();

    /// <summary>
    /// 네트워크 연결, 버튼 AddListener
    /// </summary>
    void Start()
    {
        PhotonNetwork.ConnectUsingSettings();
        nicknameAdmitButton.onClick.AddListener(NicknameAdmit);
        roomNameAdmitButon.onClick.AddListener(CreateRoom);
    }

    /// <summary>
    /// 서버가 연결되었을 때 로딩 패널 비활성화
    /// </summary>
    public override void OnConnectedToMaster()
    {
        if (loadingPanel.activeSelf)
        {
            loadingPanel.SetActive(false);
        }
    }

    /// <summary>
    /// 네트워크 연결 실패시 연걸 재시도
    /// </summary>
    /// <param name="cause"></param>
    public override void OnDisconnected(DisconnectCause cause)
    {
        base.OnDisconnected(cause);
        PhotonNetwork.ConnectUsingSettings();
    }

    /// <summary>
    /// 닉네임 설정
    /// </summary>
    public void NicknameAdmit()
    {
        // 입력한 글자가 없거나 공백일 경우 설정 불가
        if (string.IsNullOrWhiteSpace(nicknameField.text)) return;
        PhotonNetwork.NickName = nicknameField.text;
        PhotonNetwork.JoinLobby();
    }

    /// <summary>
    /// 로비 입장
    /// </summary>
    public override void OnJoinedLobby()
    { 
        nicknamePanel.SetActive(false);
        lobbyPanel.SetActive(true);
    }

    /// <summary>
    /// 방 생성
    /// </summary>
    public void CreateRoom()
    {
        // 입력한 글자가 없을 경우 설정 불가
        if (string.IsNullOrEmpty(roomNameField.text)) return;

        roomNameAdmitButon.interactable = false;

        RoomOptions options = new RoomOptions { MaxPlayers = 8 };
        options.CustomRoomPropertiesForLobby = new string[] { "Map" };
        PhotonNetwork.CreateRoom(roomNameField.text, options);
        roomNameField.text = null;
    }

    /// <summary>
    /// 방을 생성했을 때
    /// </summary>
    public override void OnCreatedRoom()
    {
        lobbyPanel.SetActive(false);
        ExitGames.Client.Photon.Hashtable roomProperty = new Hashtable();
        roomProperty["Map"] = 0;
        PhotonNetwork.CurrentRoom.SetCustomProperties(roomProperty);
    }

    /// <summary>
    /// 방에 참가했을 때
    /// </summary>
    public override void OnJoinedRoom()
    {
        lobbyPanel.SetActive(false);
        roomManager.PlayerPanelSpawn();
    }

    /// <summary>
    /// 플레이어가 방에 들어왔을 때
    /// </summary>
    /// <param name="newPlayer"></param>
    public override void OnPlayerEnteredRoom(Player newPlayer)
    {
        if (newPlayer != PhotonNetwork.LocalPlayer) roomManager.PlayerPanelSpawn(newPlayer);
    }

    /// <summary>
    /// 플레이어가 방을 나갔을 때
    /// </summary>
    /// <param name="otherPlayer"></param>
    public override void OnPlayerLeftRoom(Player otherPlayer)
    {
        roomManager.PlayerPanelDestroy(otherPlayer);
    }

    /// <summary>
    /// 방의 정보가 업데이트 되었을 때(플레이어 변동)
    /// </summary>
    /// <param name="roomList"></param>
    public override void OnRoomListUpdate(List<RoomInfo> roomList)
    {
        foreach (RoomInfo info in roomList)
        {
            if (info.RemovedFromList)
            {
                if (roomListItems.TryGetValue(info.Name, out GameObject obj))
                {
                    Destroy(obj);
                    roomListItems.Remove(info.Name);
                }
                continue;
            }

            if (roomListItems.ContainsKey(info.Name))
            {
                roomListItems[info.Name].GetComponent<RoomListItem>().Init(info);
            }
            else
            {
                GameObject roomListItem = Instantiate(roomListItemPrefab);
                roomListItem.transform.SetParent(roomListContent);
                roomListItem.GetComponent<RoomListItem>().Init(info);
                roomListItems.Add(info.Name, roomListItem);
            }
        }
    }

    /// <summary>
    /// 방의 설정 변경 시 - map
    /// </summary>
    /// <param name="propertiesThatChanged"></param>
    public override void OnRoomPropertiesUpdate(ExitGames.Client.Photon.Hashtable propertiesThatChanged)
    {
        roomManager.MapChange();
    }

    /// <summary>
    /// 플레이어 설정 변경 시 - ready
    /// </summary>
    /// <param name="target"></param>
    /// <param name="propertiesThatChanged"></param>
    public override void OnPlayerPropertiesUpdate(Player target, ExitGames.Client.Photon.Hashtable propertiesThatChanged)
    {
        roomManager.playerPanels[target.ActorNumber].ReadyCheck(target);
    }

    /// <summary>
    /// 방장이 바뀌었을 시
    /// </summary>
    /// <param name="newClientPlayer"></param>
    public override void OnMasterClientSwitched(Player newClientPlayer)
    {
        roomManager.PlayerPanelSpawn(newClientPlayer);
    }

    private void Update()
    {
        stateText.text = $"Current State : {PhotonNetwork.NetworkClientState}";
    }
}
  • RoomManager

룸(게임 대기실)에서의 모든 조작 및 UI 및 프로퍼티 변동에 대해 처리

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);
        }
    }

    /// <summary>
    /// 게임 시작과 같이 씬 전환
    /// </summary>
    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;
    }

    /// <summary>
    /// 플레이어 패널 파괴
    /// </summary>
    /// <param name="player"></param>
    public void PlayerPanelDestroy(Player player)
    {
        if (playerPanels.TryGetValue(player.ActorNumber, out PlayerPanelItem panel))
        {
            Destroy(panel.gameObject);
            playerPanels.Remove(player.ActorNumber);
        }
        else
        {
            Debug.LogError("패널이 존재하지 않음");
        }
    }

    /// <summary>
    /// 방 나가기
    /// </summary>
    public void LeaveRoom()
    {
        foreach (Player player in PhotonNetwork.PlayerList)
        {
            Destroy(playerPanels[player.ActorNumber].gameObject);
        }

        playerPanels.Clear();

        PhotonNetwork.LeaveRoom();
    }

    /// <summary>
    /// 맵의 왼쪽 이동 버튼 누르기
    /// </summary>
    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();
    }

    /// <summary>
    /// 맵의 오른쪽 이동 버튼 누르기
    /// </summary>
    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();
    }

    /// <summary>
    /// 멥 바꾸기
    /// </summary>
    public void MapChange()
    {
        mapIndex = (int)PhotonNetwork.CurrentRoom.CustomProperties["Map"];
        Debug.Log(mapIndex);
        mapImage.sprite = mapSprites[mapIndex];
    }
}
  • RoomListItem

로비에서의 방 생성과 입장과 관련된 프리팹 컴포넌트를 관리한다.

using Photon.Pun;
using Photon.Realtime;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public enum MapType
{
    Bridge, Fall, River
}

public class RoomListItem : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI roomNameText;
    [SerializeField] private TextMeshProUGUI playerCountText;
    [SerializeField] private Button joinButton;

    [SerializeField] private TextMeshProUGUI mapText;

    private string roomName;

    public void Init(RoomInfo info)
    {
        roomName = info.Name;
        roomNameText.text = $"Room Name : {roomName}";
        playerCountText.text = $"{info.PlayerCount} / {info.MaxPlayers}";
        mapText.text = $"Map : {(MapType)info.CustomProperties["Map"]}";

        joinButton.onClick.AddListener(JoinRoom);
    }

    /// <summary>
    /// 방 참가
    /// </summary>
    public void JoinRoom()
    {
        if (PhotonNetwork.InLobby)
        {
            PhotonNetwork.JoinRoom(roomName);
            joinButton.onClick.RemoveListener(JoinRoom);
        }
    }
}
  • PlayerPanelItem

룸(게임 대기실)에서의 플레이어 패널의 UI 및 프로퍼티 변동을 관리한다.

using TMPro;
using UnityEngine;
using UnityEngine.UI;
using Photon.Realtime;
using Photon.Pun;

public class PlayerPanelItem : MonoBehaviour
{
    [SerializeField] private TextMeshProUGUI nicknameText;
    [SerializeField] private TextMeshProUGUI readyText;
    [SerializeField] private Image hostImage;
    [SerializeField] private Image readyButtonImage;
    [SerializeField] private Button readyButton;

    private bool isReady;

    public void Init(Player player)
    {
        nicknameText.text = player.NickName;
        hostImage.enabled = player.IsMasterClient;
        readyButton.interactable = player.IsLocal;

        if (!player.IsLocal) return;

        isReady = false;
        
        ReadyPropertyUpdate();

        readyButton.onClick.RemoveListener(ReadyButtonClick);
        readyButton.onClick.AddListener(ReadyButtonClick);
    }

    /// <summary>
    /// Ready 버튼 클릭
    /// </summary>
    public void ReadyButtonClick()
    {
        isReady = !isReady;

        readyText.text = isReady ? "Ready" : "Click";
        readyButtonImage.color = isReady? Color.green : Color.grey;

        ReadyPropertyUpdate();
    }

    /// <summary>
    /// Ready 커스텀 프로퍼티 반영
    /// </summary>
    public void ReadyPropertyUpdate()
    {
        ExitGames.Client.Photon.Hashtable playerProperty = new ExitGames.Client.Photon.Hashtable();
        playerProperty["Ready"] = isReady;
        PhotonNetwork.LocalPlayer.SetCustomProperties(playerProperty);
    }

    /// <summary>
    /// Ready 여부 체크
    /// </summary>
    /// <param name="player"></param>
    public void ReadyCheck(Player player)
    {
        if (player.CustomProperties.TryGetValue("Ready", out object value))
        {
            readyText.text = (bool)value ? "Ready" : "Click";
            readyButtonImage.color = (bool)value ? Color.green : Color.grey;
        }
    }
}
  • ChatManager

룸(게임 대기실)에서의 채팅창의 UI 변동 및 데이터를 관리한다.

using TMPro;
using UnityEngine;
using UnityEngine.UI;
using Photon.Pun;

public class ChatManager : MonoBehaviourPun
{
    [SerializeField] private TMP_InputField chatField;
    [SerializeField] private ScrollRect scrollRect;
    [SerializeField] private GameObject chatTextPrefab;
    public Transform chatContext;

    /// <summary>
    /// InputField AddListener
    /// </summary>
    private void Start()
    {
        chatField.onEndEdit.AddListener(HandleInput);
    }

    /// <summary>
    /// 채팅창의 Input 반영
    /// </summary>
    /// <param name="text"></param>
    private void HandleInput(string text)
    {
        if (!Input.GetKeyDown(KeyCode.Return)) return;

        if(!string.IsNullOrWhiteSpace(text))
        {
            photonView.RPC("SendMessage", RpcTarget.All, PhotonNetwork.NickName, text);
            chatField.text = "";
            chatField.ActivateInputField();
        }
    }

    /// <summary>
    /// 채팅창에 메시지를 보냄
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="message"></param>
    [PunRPC]
    private void SendMessage(string sender, string message)
    {
        GameObject item = Instantiate(chatTextPrefab, chatContext);
        item.GetComponent<TextMeshProUGUI>().text = $"{sender} : {message}";
        Canvas.ForceUpdateCanvases();
        scrollRect.verticalNormalizedPosition = 0f;
    }
}

1.2 인게임 씬

  • TestNetworkManager

인게임에서의 네트워크를 관리하는 매니저이다.

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

public class TestNetworkManager : MonoBehaviourPunCallbacks
{
    Coroutine monsterCoroutine;

    /// <summary>
    /// 시작과 같이 플레이어 소환, 몬스터 소환 코루틴 진행
    /// </summary>
    void Start()
    {
        PhotonNetwork.LocalPlayer.NickName = $"Player_{PhotonNetwork.LocalPlayer.ActorNumber}";
        PlayerSpawn();
        if (PhotonNetwork.IsMasterClient && monsterCoroutine == null)
        {
            monsterCoroutine = StartCoroutine(MonsterSpawn());
        }
    }

    public override void OnConnectedToMaster()
    {
        PhotonNetwork.JoinRandomOrCreateRoom();
    }
    
    /// <summary>
    /// 방장 변경 시 몬스터 소환 코루틴 진행
    /// </summary>
    /// <param name="newMasterClient"></param>
    public override void OnMasterClientSwitched(Player newMasterClient)
    {
        if (PhotonNetwork.IsMasterClient && monsterCoroutine == null)
        {
            monsterCoroutine = StartCoroutine(MonsterSpawn());
        }
    }

    /// <summary>
    /// 플레이어가 방에 없으면 코루틴 종료
    /// </summary>
    /// <param name="otherPlayer"></param>
    public override void OnPlayerLeftRoom(Player otherPlayer)
    {
        if (PhotonNetwork.CountOfPlayers == 0 && monsterCoroutine != null)
        {
            StopCoroutine(monsterCoroutine);
        }
    }

    /// <summary>
    /// 몬스터 소환 코루틴
    /// </summary>
    /// <returns></returns>
    private IEnumerator MonsterSpawn()
    {
        while (true)
        {
            Debug.Log("몬스터 소환");
            yield return new WaitForSeconds(3f);
            Vector3 spawnPos = new Vector3(Random.Range(0,5), 0.5f, Random.Range(0,5));
            PhotonNetwork.Instantiate("Monster", spawnPos, Quaternion.identity);
        }
    }

    /// <summary>
    /// 플레이어 소환
    /// </summary>
    private void PlayerSpawn()
    {
        Vector3 spawnPos = new Vector3(Random.Range(0, 5), 1, Random.Range(0, 5));
        PhotonNetwork.Instantiate("Player", spawnPos, Quaternion.identity);
    }

    /// <summary>
    /// 플레이어 입장 시
    /// </summary>
    /// <param name="player"></param>
    public override void OnPlayerEnteredRoom(Player player)
    {
        Debug.Log($"{player.NickName} 입장");
    }
}
  • PlayerController

플레이어의 조작을 관리하는 컴포넌트로, 네트워크 동기화의 대상이다.

using UnityEngine;
using Photon.Pun;

public class PlayerController : MonoBehaviourPun, IPunObservable
{
    [Header("플레이어 이동")]
    [SerializeField] private float moveSpeed;
    [SerializeField] private float rotSpeed;
    private Vector3 networkPosition;
    private Quaternion networkRotation;

    [Header("플레이어 마우스/카메라 이동")]
    [SerializeField] private float mouseSensitivity;
    [SerializeField] private GameObject playerCamera;
    private float verticalLookRotation;

    [Header("플레이어 총알 발사")]
    [SerializeField] private GameObject bulletPrefab;
    [SerializeField] private Transform muzzlePoint;

    [Header("플레이어 색깔")]
    [SerializeField] private MeshRenderer meshRenderer;
    [SerializeField] private Material[] materials;
    private int playerColorIndex;

    /// <summary>
    /// 플레이어 생성 시 색깔 초기화
    /// </summary>
    private void Start()
    {
        if(photonView.IsMine)
        {
            playerColorIndex = PhotonNetwork.LocalPlayer.ActorNumber - 1;
            photonView.RPC("SetPlayerColorRPC", RpcTarget.AllBuffered, playerColorIndex);
        }
    }

    /// <summary>
    /// 플레이어 정보 동기화
    /// </summary>
    /// <param name="stream"></param>
    /// <param name="info"></param>
    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();            
            playerCamera.SetActive(true);
            CameraRotate();

            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);
            playerCamera.SetActive(false);            
        }
    }

    /// <summary>
    /// 총알 발사
    /// </summary>
    /// <param name="info"></param>
    [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);

        newBullet.GetComponent<Bullet>().SetColor(materials[playerColorIndex]);
    }

    /// <summary>
    /// 플레이어 이동
    /// </summary>
    private void Move()
    {
        Vector3 moveInput = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
        bool isMove = moveInput.magnitude != 0;
        if (isMove)
        {
            Vector3 lookForward = new Vector3(playerCamera.transform.forward.x, 0f, playerCamera.transform.forward.z).normalized;
            Vector3 lookRight = new Vector3(playerCamera.transform.right.x, 0f, playerCamera.transform.right.z).normalized;
            Vector3 moveDir = lookForward * moveInput.z + lookRight * moveInput.x;

            transform.position += moveDir * Time.deltaTime * moveSpeed;
        }
    }
    
    /// <summary>
    /// 마우스를 통한 카메라 시점 이동
    /// </summary>
    private void CameraRotate()
    {
        transform.Rotate(Vector3.up * Input.GetAxisRaw("Mouse X") * mouseSensitivity);

        verticalLookRotation += Input.GetAxisRaw("Mouse Y") * mouseSensitivity;
        verticalLookRotation = Mathf.Clamp(verticalLookRotation, -40f, 80f);

        playerCamera.transform.localEulerAngles = Vector3.left * verticalLookRotation;     
    }

    /// <summary>
    /// 플레이어 색깔 결정
    /// </summary>
    /// <param name="index"></param>
    [PunRPC]
    private void SetPlayerColorRPC(int index)
    {
        playerColorIndex = index;
        meshRenderer.material = materials[playerColorIndex];
    }
}
  • Bullet

플레이어가 발사하는 총알에 붙는 컴포넌트이다. 지연 보상이 적용되어 있다.

using UnityEngine;

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

    /// <summary>
    /// 총알 발사 및 3초 후 파괴
    /// </summary>
    private void Start()
    {
        rigid.AddForce(transform.forward * bulletSpeed, ForceMode.Impulse);
        Destroy(gameObject, 3f);
    }

    /// <summary>
    /// 총알 발사 지연 보상
    /// </summary>
    /// <param name="lag"></param>
    public void ApplyLagCompensation(float lag)
    {
        rigid.velocity = transform.forward * bulletSpeed;
        rigid.position += rigid.velocity * lag;
    }

    /// <summary>
    /// 총알의 충돌 시 작용
    /// - 플레이어 충돌 시 총알 파괴
    /// - 몬스터와 충돌 시 몬스터 파괴 및 총알 파괴
    /// </summary>
    /// <param name="collision"></param>
    public void OnCollisionEnter(Collision collision)
    {
        if(collision.gameObject.CompareTag("Player"))
        {
            Destroy(gameObject);
        }

        else if(collision.gameObject.CompareTag("Monster"))
        {
            Destroy(collision.gameObject);
            Destroy(gameObject);
        }
    }

    /// <summary>
    /// 총알의 색깔 적용
    /// </summary>
    /// <param name="material"></param>
    public void SetColor(Material material)
    {
        GetComponent<MeshRenderer>().material = new Material(material);
    }
}

2. 금일 배운 점, 시행착오 등

2.1 TPS 시점 카메라의 분배 방법

과제 내용은 기본적으로 TPS 시점의 멀티 플레이어 게임을 구현하는 것이다. 여기서 생각해봐야 하는 점은 일반적으로 카메라는 한 대 뿐이라는 것이다.
플레이어는 각자의 시점에서의 3인칭 카메라를 들고 있어야 하므로, 이를 구현하기 위해서는 시네머신을 사용해야 한다고 생각했다.

플레이어의 자식 오브젝트에 Virtual Camera를 두고서 그대로 게임을 재생시키니, 이상하게 작동하는 것을 확인했다.
플레이어 1과 플레이어 2는 각각 따로 작동을 잘 하긴 했지만, 카메라의 시점이 플레이어 1은 플레이어 2의 시점, 플레이어 2는 1의 시점에서 보이는 현상이 발생했다.

이를 위해서는 Virtual Camera를 각각의 플레이어에 연동된 것만 활성화하도록 하는 처리가 필요하다.

Virtual Camera 자체를 켜고 끌 수 있는 방법을 찾아보다가 이쪽으로는 잘 안풀려서, Virtual Camera라는 오브젝트 자체를 게임오브젝트로 받은 다음, 자신의 photonView일 때 활성화하고, 아닐 때는 비활성화하는 방식을 사용했다.

[Header("플레이어 마우스/카메라 이동")]
[SerializeField] private float mouseSensitivity;
[SerializeField] private GameObject playerCamera;
private float verticalLookRotation;

...

private void Update()
{
    if (photonView.IsMine)
    {         
        playerCamera.SetActive(true);
    }
    else
    {
        playerCamera.SetActive(false);            
    }
}

2.2 플레이어 캐릭터의 움직임 반영

기존의 플레이어의 움직임이 제대로 작동하지 않는 문제를 겪었다. 기존 코드 자체가 월드 스페이스 단위로 간단하게 만들었다 보니, TPS 시점의 전후좌우를 구현하려면 코드를 다시 짜야만 했다.

처음엔 게임 오브젝트 자체의 전후좌우를 기준으로 플레이어를 움직이려 했지만, 이보다는 카메라 시점을 기준으로 하는 것이 더 자연스러운 움직임이 나온다는 것을 알게 되어 카메라 시점을 기준으로 플레이어의 움직임을 수정했다.

/// <summary>
/// 플레이어 이동
/// </summary>
private void Move()
{
    Vector3 moveInput = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
    bool isMove = moveInput.magnitude != 0;
    if (isMove)
    {
        Vector3 lookForward = new Vector3(playerCamera.transform.forward.x, 0f, playerCamera.transform.forward.z).normalized;
        Vector3 lookRight = new Vector3(playerCamera.transform.right.x, 0f, playerCamera.transform.right.z).normalized;
        Vector3 moveDir = lookForward * moveInput.z + lookRight * moveInput.x;

        transform.position += moveDir * Time.deltaTime * moveSpeed;
    }
}

또한 여기서 카메라의 움직임도 구현해야 하는데, 마우스를 따라서 카메라를 움직이는 과정을 다음과 같이 구현했다.

/// <summary>
/// 마우스를 통한 카메라 시점 이동
/// </summary>
private void CameraRotate()
{
    transform.Rotate(Vector3.up * Input.GetAxisRaw("Mouse X") * mouseSensitivity);

    verticalLookRotation += Input.GetAxisRaw("Mouse Y") * mouseSensitivity;
    verticalLookRotation = Mathf.Clamp(verticalLookRotation, -40f, 80f);

    playerCamera.transform.localEulerAngles = Vector3.left * verticalLookRotation;     
}

2.3 플레이어의 색깔 및 총알 색깔 반영하기

실습 과제의 조건에는 플레이어의 색과 총알 색이 구분될 수 있도록 입장한 플레이어가 다른 색깔이 되도록 만들어야 한다는 조건이 있었다. 이 부분에 대해서 어떻게 구현할까 고민해보았다.

  1. 우선 이 게임은 특성상 8명의 플레이어가 있기 때문에 8 색의 재질을 만들어서 플레이어가 들고 있게 한다.
  2. 각각의 플레이어는 입장한 순서에 따라 해당 배열에 저장된 재질로 자신의 색깔과 총알의 색깔을 결정한다.

이와 같은 아이디어를 바탕으로 초기에는 다음과 같이 짰다.

using UnityEngine;
using Photon.Pun;

public class PlayerController : MonoBehaviourPun, IPunObservable
{
    ...

    [SerializeField] private MeshRenderer meshRenderer;
    [SerializeField] private Material[] materials;

    ...

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

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

    [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 SetPlayerColor()
    {
        meshRenderer.material = materials[PhotonNetwork.CountOfPlayersInRooms];
        bulletPrefab.GetComponent<MeshRenderer>().material = meshRenderer.material;
    }
}

하지만 이 방법에는 다른 플레이어의 색깔을 반영하는 방법이 없었고, 또한 총알 색깔의 경우 맨 마지막에 입장한 플레이어 기준의 색깔로 통일되어 나가는 문제가 발생했다.

그렇다면 이걸 반영하기 위해서는 어떻게 처리해야 할까 고민해 보았고, 생각할 수 있는 방법을 다음과 같이 생각했다.

  1. 우선 Update문에서 처리를 하는 것으로 인해 총알의 색이 실시간으로 반영되는 문제가 있다. Start문에서만 실행되도록 바꾸는 편이 좋아 보인다.
  2. 또한 photonView.IsMine 일때만 해당 색깔 변화를 처리하는 편이 좋아 보이니, 플레이어의 색깔이 변하는 함수를 RPC로 처리하고, 총알 색깔 변화 부분은 총알 쪽에서 처리하도록 하자.

이와 같은 방식으로 코드를 아래와 같이 수정했다.

  • PlayerController
[Header("플레이어 색깔")]
[SerializeField] private MeshRenderer meshRenderer;
[SerializeField] private Material[] materials;
private int playerColorIndex;

/// <summary>
/// 플레이어 생성 시 색깔 초기화
/// </summary>
private void Start()
{
    if(photonView.IsMine)
    {
        playerColorIndex = PhotonNetwork.LocalPlayer.ActorNumber - 1;
        photonView.RPC("SetPlayerColorRPC", RpcTarget.AllBuffered, playerColorIndex);
    }
}

...

private void Update()
{
    if (photonView.IsMine)
    {
        Move();            
        playerCamera.SetActive(true);
        CameraRotate();

        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);
        playerCamera.SetActive(false);            
    }
}

/// <summary>
/// 총알 발사
/// </summary>
/// <param name="info"></param>
[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);
	
    // 총알 색깔 처리
    newBullet.GetComponent<Bullet>().SetColor(materials[playerColorIndex]);
}

...

/// <summary>
/// 플레이어 색깔 결정
/// </summary>
/// <param name="index"></param>
[PunRPC]
private void SetPlayerColorRPC(int index)
{
    playerColorIndex = index;
    meshRenderer.material = materials[playerColorIndex];
}
  • Bullet
/// <summary>
/// 총알의 색깔 적용
/// </summary>
/// <param name="material"></param>
public void SetColor(Material material)
{
    GetComponent<MeshRenderer>().material = new Material(material);
}

2.4 플레이어의 정보를 다음 씬으로 가져오기

이제 실습 과제에서 요구한 조건 자체는 거의 다 클리어했지만, 가장 큰 문제로 두 가지로 나뉜 씬이 연결되지 않는다는 문제가 발생했다.

씬이 옮겨짐에 따라 네트워크를 다시 연결해야 한다고 생각했지만 PhotonNetwork.ConnectUsingSettings();으로 다시 불러왔을 때 네트워크를 또 다시 활성화할 수 없다는 경고 알림과 함께 정상작동되지 않았다. 그 경고에서는 Disconnect를 하고 난 다음에 다시 연결을 시도해 보라고 해서, 이와 같이 처리를 해 보았다.
하지만 이와 같이 처리하면 접속한 두 플레이어가 각각 따로 다음 씬에 도달하는 문제가 생겼다.

방에 있던 두 사람을 같은 씬에 보내기 위해서는 어떻게 처리해야 할까 고민을 했고, 생각보다 단순하게 처리할 수 있었다.

처음부터 네트워크 설정을 다시 할 필요 없이 그대로 씬을 옮기고, Start문에서 플레이어를 불러오는 방식으로 쳐리했다.

/// <summary>
/// 시작과 같이 플레이어 소환, 몬스터 소환 코루틴 진행
/// </summary>
void Start()
{
    PhotonNetwork.LocalPlayer.NickName = $"Player_{PhotonNetwork.LocalPlayer.ActorNumber}";
    PlayerSpawn();
    if (PhotonNetwork.IsMasterClient && monsterCoroutine == null)
    {
        monsterCoroutine = StartCoroutine(MonsterSpawn());
    }
}
profile
게임 만들러 코딩 공부중

0개의 댓글