이제부터 멀티 플레이어 게임을 구성하기 위한 준비를 해 보려고 한다. 이를 위해 우리가 사용할 패키지는 Photon Unity Networking, PUN이다.
Photon을 사용하기 위한 환경을 구성하기 위한 단계는 다음과 같다.
https://www.photonengine.com/ko-kr
Photon은 사용하기 위해서 먼저 회원가입 절차를 진행해야 하고, 회원 등록을 하면 정말 간단하게 이메일 주소 하나만 입력하면 바로 가입이 된다.(다만 로그인을 하려면 해당 입력한 이메일 주소로 전송된 메일로 비밀번호 생성 절차를 진행해야 함.)
계정을 생성한 다음 관리화면으로 이동하기를 눌러서 새 어플리케이션을 생성하고, PUN으로 생성한다.
그다음 에셋스토에서 PUN 2 무료버전을 다운받는다.
해당 패키지를 Import하면 아래와 같이 PUN Wizard가 뜨는데, 혹시 창을 꺼 버렸다면 Window에서 다시 켜 주면 되고, AppID를 입력해주면 된다.
여기서 이 AppID는 아까 Photon에서 만들었던 새 어플리케이션에 있다.
이와 같이 세팅하면 네트워크를 사용할 준비는 완료되었다. 여기에서 네트워크 테스트를 좀 더 편히 하기 위해 ParrelSync라는 패키지도 추가로 활용할 것이다.
https://github.com/VeriorPies/ParrelSync#
해당 깃허브 사이트의 내용을 보면 아래와 같이 패키지를 추가할 수 있는 방법이 있으며 패키지 매니저에서 +키를 누르고 적혀 있는 링크를 복사하면 패키지가 바로 추가가 된다.
ParrelSync 패키지는 네트워크 테스트를 진행하기 위해 같은 프로젝트를 똑같이 클론하여 만들 수 있는 기능이다. 이에 따라서 두 명의 플레이어를 구현해주는 역할을 하여 테스트를 진행하는 데에 도움울 줄 것이다.
간단하게 아래와 같은 시스템을 만들어보고자 한다
게임에 접속하면 이름을 입력한다. 이후 서버에 접속하면 방을 생성하거나 방을 들어갈 수 있는 기능을 만들어보자.
네트워크매니저를 만들어보자. 여기에서 Photon을 설치했으니 using Photon.Pun이라는 네임스페이스를 추가하고 해당 네임스페이스에서 제공하는 MonoBehaviourPunCallbacks를 사용해보자.
이 클래스를 상속받는 것으로 Photon에서 사용할 수 있는 다양한 네트워크 관련 기능을 override 함수로 사용할 수 있다.
해당 정의를 살펴보면 가상함수로 선언된 아주 많은 기능이 있다.
(OnConnected, OnCreatedRoom, OnJoinedRoom, OnJoinedLobby, OnPlayerEnteredRoom ... )
네트워크를 연결하는 키워드는 다음과 같다.
PhotonNetwork.ConnectUsingSettings();
따라서 네트워크를 구성하는 것은 Start() 와 OnDisconnected() 에 모두 넣어준다.
(start - 시작 시에 네트워크 연결, OnDisconnected - 연결이 끊어졌을 때 다시 시도함)
현재 네트워크 접속상태를 확인하는 키워드는 다음과 같다.
PhotonNetwork.NetworkClientState;
따라서 이를 UI상으로 확인하기 좋도록, stateText라는 텍스트 UI를 추가하고 다음과 같이 작성한다.
private void Update()
{
stateText.text = $"Current State : {PhotonNetwork.NetworkClientState}";
}
이와 같이 현 상태를 확인할 수 있다
전체적인 코드의 흐름을 보면 어떤 상황에서 UI패널을 활성화시키고 비활성화시키는 등의 과정이 대부분이다.
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;
private Dictionary<string, GameObject> roomListItems = new Dictionary<string, GameObject>();
[SerializeField] RoomManager roomManager;
// 시작과 동시에 네트워크 연결 시도
void Start()
{
PhotonNetwork.ConnectUsingSettings();
nicknameAdmitButton.onClick.AddListener(NicknameAdmit);
roomNameAdmitButon.onClick.AddListener(CreateRoom);
}
// 연결이 되었을 때 로딩패널 비활성화
public override void OnConnectedToMaster()
{
base.OnConnectedToMaster();
if (loadingPanel.activeSelf)
{
loadingPanel.SetActive(false);
}
else
{
PhotonNetwork.JoinLobby();
}
}
// 연결 실패 시 재연결시도
public override void OnDisconnected(DisconnectCause cause)
{
base.OnDisconnected(cause);
PhotonNetwork.ConnectUsingSettings();
}
// 닉네임이 입력되면 해당 닉네임이 네트워크에 저장되고 로비로 이동(빈칸, 공백 닉네임 불가)
public void NicknameAdmit()
{
if (string.IsNullOrWhiteSpace(nicknameField.text))
{
Debug.LogError("닉네임 입력값 없음");
return;
}
PhotonNetwork.NickName = nicknameField.text;
PhotonNetwork.JoinLobby();
}
// 로비에 진입했을 때 닉네임 패널 끄기, 로비패널 켜기
public override void OnJoinedLobby()
{
base.OnJoinedLobby();
nicknamePanel.SetActive(false);
lobbyPanel.SetActive(true);
Debug.Log("로비 참가");
}
// 최대 인원 8명인 방을 생성
public void CreateRoom()
{
if (string.IsNullOrEmpty(roomNameField.text))
{
Debug.LogError("방 이름 입력값 없음");
return;
}
roomNameAdmitButon.interactable = false;
RoomOptions options = new RoomOptions { MaxPlayers = 8 };
PhotonNetwork.CreateRoom(roomNameField.text, options);
roomNameField.text = null;
}
// 방을 생성했을 때 로비 패널 비활성화
public override void OnCreatedRoom()
{
base.OnCreatedRoom();
lobbyPanel.SetActive(false);
}
// 생성되어 있는 방에 참가했을 때 로비 패널 비활성화 및 캐릭터패널 생성
public override void OnJoinedRoom()
{
base.OnJoinedRoom();
lobbyPanel.SetActive(false);
roomManager.PlayerPanelSpawn();
}
...
이와 같이 네트워크를 구성하는 방식은 그 자체로 어려워보이지는 않지만, UI를 다루는 능력이 어느 정도 필요하다. 그래서 UI를 구성한 방식에 대해 좀 다뤄보고자 한다.
캔버스는 이와 같이 구성했으며, 모든 상황에서 상태를 표시하기 위한 CurrentState 텍스트를 맨 아래에 두어 어떤 상태이든 확인할 수 있도록 하였다. 패널 순서가 중요하므로 유의하도록 한다.
그냥 말 그대로 Loding Panel이다. 네트워크가 처음 연결되었을 때 한 번만 표시되는 패널이기 때문에 이후로 활성화되는 일이 없다.
이름 그대로 닉네임을 입력하는 UI로, 닉네임을 입력한 후로 현재까지는 보지 않을 화면이다. 여기에서의 PhotonNetwork.NickName = nicknameField.text; 라는 키워드로 이름이 저장된다.
이후 PhotonNetwork.JoinLobby();로 로비에 진입한다.
로비 패널의 경우 이와 같이 좌측 상단에는 방 리스트를 표시하는 스크롤 뷰와, 오른쪽에 이름을 입력하고 방을 생성할 수 있는 요소로 구성되어 있다.
여기서 버튼으로 RoomListPrefab을 만들었는데 방이 여러 개 있다는 가정 하에 이와 같이 표시된다. 이를 위해 ScrollView 의 Content 항목에 이와 같이 UI 요소를 추가해야 하니 참고하도록 하자.
로비에서 방으로 입장했을 때의 패널이다. 여기에서 좌측 상단의 공간이 플레이의 목록을 표시하는 부분으로, 프리팹으로는 이와 같이 표시된다.
실제로 사람이 여러 명이 접속해있다 가정할 때 이와 같이 표시된다.
*방장(마스터)만 텍스트 위쪽의 사각형 표시가 생긴다.
참고로 그리드 레이어의 설정도 이와 같이 하고서 UI를 구성해보자.
오늘 배운 것은 전체적으로 어려운 부분은 없었고, Photon에 어떤 기능이 있고 키워드가 있는지를 알아가는 중심으로 공부한 걸 정리하면 될 것 같다. 어디까지나 아직 처음 다뤄봐서 익숙하지 않은 것일 뿐, 여러 번 다뤄보면 그리 어렵지는 않을 것 같다.
UI적으로 연결해야 할 내용이 많다. 전체적인 접속과 관련된 기능을 담당하며, UI의 활성화 및 비활성화 등의 기능에 치중하고 있다.
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;
private Dictionary<string, GameObject> roomListItems = new Dictionary<string, GameObject>();
[SerializeField] RoomManager roomManager;
// 시작과 동시에 네트워크 연결 시도
void Start()
{
PhotonNetwork.ConnectUsingSettings();
nicknameAdmitButton.onClick.AddListener(NicknameAdmit);
roomNameAdmitButon.onClick.AddListener(CreateRoom);
}
// 연결이 되었을 때 로딩패널 비활성화
public override void OnConnectedToMaster()
{
base.OnConnectedToMaster();
if (loadingPanel.activeSelf)
{
loadingPanel.SetActive(false);
}
else
{
PhotonNetwork.JoinLobby();
}
}
// 연결 실패 시 재연결시도
public override void OnDisconnected(DisconnectCause cause)
{
base.OnDisconnected(cause);
PhotonNetwork.ConnectUsingSettings();
}
// 닉네임이 입력되면 해당 닉네임이 네트워크에 저장되고 로비로 이동(빈칸, 공백 닉네임 불가)
public void NicknameAdmit()
{
if (string.IsNullOrWhiteSpace(nicknameField.text))
{
Debug.LogError("닉네임 입력값 없음");
return;
}
PhotonNetwork.NickName = nicknameField.text;
PhotonNetwork.JoinLobby();
}
// 로비에 진입했을 때 닉네임 패널 끄기, 로비패널 켜기
public override void OnJoinedLobby()
{
base.OnJoinedLobby();
nicknamePanel.SetActive(false);
lobbyPanel.SetActive(true);
Debug.Log("로비 참가");
}
// 최대 인원 8명인 방을 생성
public void CreateRoom()
{
if (string.IsNullOrEmpty(roomNameField.text))
{
Debug.LogError("방 이름 입력값 없음");
return;
}
roomNameAdmitButon.interactable = false;
RoomOptions options = new RoomOptions { MaxPlayers = 8 };
PhotonNetwork.CreateRoom(roomNameField.text, options);
roomNameField.text = null;
}
// 방을 생성했을 때 로비 패널 비활성화
public override void OnCreatedRoom()
{
base.OnCreatedRoom();
lobbyPanel.SetActive(false);
}
// 생성되어 있는 방에 참가했을 때 로비 패널 비활성화 및 캐릭터패널 생성
public override void OnJoinedRoom()
{
base.OnJoinedRoom();
lobbyPanel.SetActive(false);
roomManager.PlayerPanelSpawn();
}
// 플레이어가 방을 나갔을 때, 플레이어 패널을 파괴함
public override void OnPlayerLeftRoom(Player otherPlayer)
{
roomManager.PlayerPanelDestroy(otherPlayer);
}
// 방 리스트를 업데이트할 때마다 호출되는 함수(방이 생성됐을 때, 방이 없어졌을 때 둘 다 호출)
// 딕셔너리를 통해서 방의 이름과 해당 프리팹을 등록하고,
// 방이 등록되었을 때 프래팹을 생성하면서 딕셔너리에 정보를 등록,
// 방이 사라졌을 때 해당 프리팹을 파괴하면서 딕셔너리에서 정보를 제거한다.
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);
}
}
}
// 실시간으로 네트워크의 접속 상태를 확인하기 위한 텍스트 UI 표시
private void Update()
{
stateText.text = $"Current State : {PhotonNetwork.NetworkClientState}";
}
}
다음으론 방 리스트를 표시하는 UI를 컨트롤하는 컴포넌트이다.
UI로 방의 이름과 인원수를 표시하고, 클릭했을 때 방에 들어갈 수 있는 기능 정도만 있다. 해당 프리팹을 NetworkManager에 등록하여 프리팹을 생성하고 파괴하는 방식으로 방 생성 및 삭제가 적용된다.
using Photon.Pun;
using Photon.Realtime;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class RoomListItem : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI roomNameText;
[SerializeField] private TextMeshProUGUI playerCountText;
[SerializeField] private Button joinButton;
private string roomName;
// 방 이름, 인원 수 등의 UI 반영
public void Init(RoomInfo info)
{
roomName = info.Name;
roomNameText.text = $"Room Name : {roomName}";
playerCountText.text = $"{info.PlayerCount} / {info.MaxPlayers}";
joinButton.onClick.AddListener(JoinRoom);
}
// 방에 입장하기
public void JoinRoom()
{
// 플레이어가 로비에 있는지 확인하는 과정을 거칠 것.
if (PhotonNetwork.InLobby)
{
PhotonNetwork.JoinRoom(roomName);
joinButton.onClick.RemoveListener(JoinRoom);
}
}
}
실질적으로 방에 입장했을 때, 방의 요소를 컨트롤하기 위한 매니저이다. 추가적으로 더 구현할 내용이 남아있기 하지만 오늘 구현한 내용은 방을 입장하고 나가는 것까지 구현했다.
실질적인 구조를 분석해보면, NetworkManager에서 방을 관리하는 것과 비슷한 구조로 관리하고 있다는 것을 확인할 수 있다.
using System.Collections.Generic;
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;
using UnityEngine.UI;
public class RoomManager : MonoBehaviour
{
[SerializeField] private Button startButton;
[SerializeField] private Button leaveButton;
[SerializeField] private GameObject playerPanelItemPrefabs;
[SerializeField] private Transform playerPanelContent;
private Dictionary<int, PlayerPanelItem> playerPanels = new Dictionary<int, PlayerPanelItem>();
private void Start()
{
leaveButton.onClick.AddListener(LeaveRoom);
}
/// <summary>
/// 기존 플레이어가 새로은 플레이어 입장시 호출, 패널을 생성한다.
/// </summary>
/// <param name="player"></param>
public void PlayerPanelSpawn(Player player)
{
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()
{
if(!PhotonNetwork.IsMasterClient)
{
startButton.interactable = false;
}
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 PlayerPanelDestroy(Player player)
{
if(playerPanels.TryGetValue(player.ActorNumber, out PlayerPanelItem panel))
{
Destroy(panel.gameObject);
playerPanels.Remove(player.ActorNumber);
}
else
{
Debug.LogError("패널이 존재하지 않음");
}
}
// 방을 나갈 때 호출한다. 위의 패널을 파괴하는 함수를 사용한다.
public void LeaveRoom()
{
foreach(Player player in PhotonNetwork.PlayerList)
{
Destroy(playerPanels[player.ActorNumber].gameObject);
}
playerPanels.Clear();
PhotonNetwork.LeaveRoom();
}
}
플레이어 패널 하나를 컨트롤 하는 컴포넌트로, 현재는 UI 초기화 함수만 있다.
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using Photon.Realtime;
public class PlayerPanelItem : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI nicknameText;
[SerializeField] private TextMeshProUGUI readyText;
[SerializeField] private Image hostImage;
[SerializeField] private Image readyButtonImage;
[SerializeField] private Button readyButton;
public void Init(Player player)
{
nicknameText.text = player.NickName;
hostImage.enabled = player.IsMasterClient;
readyButton.interactable = player.IsLocal;
}
}
ParrelSync를 통해 두 명의 플레이어를 가정하고 실행하면 이와 같이 나온다.