본문 바로가기
Unity 강의/Unity Course(2) - 절대강좌! 유니티

[Unity Course 2] 15. 포톤 클라우드를 활용한 네트워크 게임 4

by 첨부엉. 2024. 7. 4.
위키북스 출판사 이재현 저자님 의 '절대강좌! 유니티' 책을 참고하여 필기한 내용입니다.

로비 제작

로비는 네트워크에 접속한 모든 플레이어가 대기하는 장소

방을 생성하거나 다른 방에 입장할 수 있는 기능을 제공해야됨

포톤 클라우드에서 로비에 접속해야만 현재 생성된 룸의 정보를 서버로부터 받아올 수 있음 

 

로비 씬 제작

기존에 만들던 SampleScene 은 BattleField 로 변경한 후 복제하고 복제한 씬의 이름은 Lobby로 바꿨다.

 

포톤 클라우드에 접속하는 과정은 Lobby씬에서 처리하기 위해 BattleField 안에 있는 PhotonManager 오브젝트는 삭제

 

Lobby 씬에 Animator와 CharacterController 컴폰너트만 연결되어 있는 Player 프리팹을 넣고 

Main Camera의 Cinemachine Brain 컴포넌트를 삭제한다.

 

씬 뷰에서 원하는 화면 구도록 이동하고 하이러키 뷰에서 Main Camera를 선택하여 Ctrl+ Shift+ F ( or GameObject - Align with view) 를 선택하면 바뀐다.

 

Global Volume의 Volume 컴포넌트에서  Depth Of Field, Color Adjustemrmt 속성을 설정해준다.

로그인 UI 제작 

네트워크 게임상 각 플레이어를 식별하기 위해 유저명을 입력받아야함

위와 같이 만들어 준다.

 

나는 책과 다르게 자리 잡기 편하기 위해 Pannel-Login group 오브젝트에

Grid Layot Group 컴포넌트를 추가하였다. 사용하기 싫으면 그냥 적절하게 배치하기

PhotonManager.cs 함수를 아래와 같이 수정하기

더보기
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using TMPro;

public class PhotonManager : MonoBehaviourPunCallbacks
{
    // 게임의 버전
    private readonly string version = "1.0";
    // 유저의 닉네임
    private string userId = "Zark";

    // 유저명을 입력할 TExtMEshPRo Input Field
    public TMP_InputField userIF;
    // 룸 이름을 입력할 TextMEshPro Input Field   
    public TMP_InputField roomNameIF;

    private void Awake()
    {
        // 마스터 클라이언트의 씬 자동 동기화 옵션
        PhotonNetwork.AutomaticallySyncScene = true;
        // 게임 버전 설정
        PhotonNetwork.GameVersion = version;
        // 접속 유저의 닉네임 설정
        // PhotonNetwork.NickName = userId;

        // 포톤 서버와의 데이터의 초당 전송 횟수
        Debug.Log(PhotonNetwork.SendRate);

        // 포톤 서버 접속 
        PhotonNetwork.ConnectUsingSettings();
    }

    private void Start()
    {
        // 저장된 유저명을 로드
        userId = PlayerPrefs.GetString("USER_ID", $"USER_{Random.Range(1, 21):00}");
        userIF.text = userId;
        // 접속 유저의 닉네임 등록
        PhotonNetwork.NickName = userId;
    }
    /// <summary>
    /// 유저명을 설정하는 로직
    /// </summary>
    public void SetUserId()
    {
        if(string.IsNullOrEmpty(userIF.text))
        {
            userId = $"USER_{Random.Range(1, 21):00}";
        }
        else
        {
            userId = userIF.text;
        }

        // 유저명 저장
        PlayerPrefs.SetString("USER_ID", userId);
        // 접속 유저의 닉네임 등록
        PhotonNetwork.NickName = userId;
    }

    /// <summary>
    /// 룸 명의 입력 여부를 확인하는 로직
    /// </summary>
    string SetRoomName()
    {
        if(string.IsNullOrEmpty(roomNameIF.text))
        {
            roomNameIF.text = $"ROOM_{Random.Range(1, 101):000}";
        }
        return roomNameIF.text;
    }

    // 포톤 서버에 접속 후 호출되는 콜백 함수
    public override void OnConnectedToMaster()
    {
        Debug.Log("Connected to Master!");
        Debug.Log($"PhotonNetwork.InLobby = {PhotonNetwork.InLobby}");
        PhotonNetwork.JoinLobby();
    }
    // 로비에 접속 후 호출되는 콜백 함수
    public override void OnJoinedLobby()
    {
        Debug.Log($"PhotonNetwork.InLobby = {PhotonNetwork.InLobby}");
        // 수동으로 저복하기 위해 자동 입장은 주석 처리
        // PhotonNetwork.JoinRandomRoom();
    }
    // 랜덤한 룸 입장이 실패했을 경우 호출되는 콜백 함수
    public override void OnJoinRandomFailed(short returnCode, string message)
    {
        Debug.Log($"JoinRandom Failed {returnCode}:{message}");
        OnMakeRoomClick();

        // 룸의 속성 정의
        //RoomOptions ro = new RoomOptions();
        //ro.MaxPlayers = 20;     // 룸에 입장할 수 있는 최대 접속자 수
        //ro.IsOpen = true;       // 룸의 오픈 여부
        //ro.IsVisible = true;    // 로비에서 룸 목록에 노출시킬지 여부

        // 룸 생성
        //PhotonNetwork.CreateRoom("My Room", ro);
    }
    // 룸 생성이 완료된 후 호출되는 콜백 함수
    public override void OnCreatedRoom()
    {
        Debug.Log("Created Room");
        Debug.Log($"Room Name = {PhotonNetwork.CurrentRoom.Name}");
    }


    // 룸 생성이 완료 된 후 호출되는 콜백 함수
    public override void OnJoinedRoom()
    {
        Debug.Log($"PhotonNetwork.InRoom = {PhotonNetwork.InRoom}");
        Debug.Log($"Player Count = {PhotonNetwork.CurrentRoom.PlayerCount}");

        foreach(var player in PhotonNetwork.CurrentRoom.Players)
        {
            Debug.Log($"{player.Value.NickName},{player.Value.ActorNumber}");
        }

        // 출현 위치 정보를 배열에 저장
        //Transform[] points = GameObject.Find("SpawnPointGroup").GetComponentsInChildren<Transform>();
        //int idx = Random.Range(1, points.Length);

        // 네트워크상에 캐릭터 생성
        //PhotonNetwork.Instantiate("Player", points[idx].position, points[idx].rotation, 0);

        // 마스터 클라이언트인 경우에 룸에 입장한 후 전투 씬을 로딩한다
        if(PhotonNetwork.IsMasterClient)
        {
            PhotonNetwork.LoadLevel("BattleField");
        }
    }

    #region UI_BUTTON_EVENT
    public void OnLoginClick()
    {
        // 유저명 저장
        SetUserId();

        // 무작위로 추출한 룸으로 입장
        PhotonNetwork.JoinRandomRoom();
    }
    public void OnMakeRoomClick()
    {
        // 유저명 저장
        SetUserId();

        // 룸의 속성 정의
        RoomOptions ro = new RoomOptions();
        ro.MaxPlayers = 20;     // 룸에 입장할 수 있는 최대 접속자 수
        ro.IsOpen = true;       // 룸의 오픈 여부
        ro.IsVisible = true;    // 로비에서 룸 목록에 노출시킬지 여부

        // 룸 생성
        PhotonNetwork.CreateRoom(SetRoomName(), ro);
    }

    #endregion
}

 

Start 함수는 처음 시작했을 때 저장되어 있던 USER_ID 키로 저장한 유저명이 있다면 해당 값을 표시함 

없을 때는 USER_01부터 20까지 랜덤한 값으로 유저명을 저장함

private void Start()
{
    // 저장된 유저명을 로드
    userId = PlayerPrefs.GetString("USER_ID", $"USER_{Random.Range(1, 21):00}");
    userIF.text = userId;
    // 접속 유저의 닉네임 등록
    PhotonNetwork.NickName = userId;
}

 

 

SetUserId 함수는 로그인 UI 의 Login 버튼, Make Room 버튼을 클릭했을 때 유저명의 변경 사항을 최종 확인하고 PlayerPrefs를 사용해 유저명을 저장한 후 PhotonNetwork.NickName을 설정

public void SetUserId()
{
    if(string.IsNullOrEmpty(userIF.text))
    {
        userId = $"USER_{Random.Range(1, 21):00}";
    }
    else
    {
        userId = userIF.text;
    }

    // 유저명 저장
    PlayerPrefs.SetString("USER_ID", userId);
    // 접속 유저의 닉네임 등록
    PhotonNetwork.NickName = userId;
}

 

 

씬을 로딩하는 함수는 유니티에서 제공하는 SceneManagerment.ScenMAnager.LoadScene 함수 대신에 PhotonNetwork.LoadLEvel 함수를 사용함

이 함수는 다른 씬을 로딩하기 전에 데이터 송수신을 잠시 멈추고 다른 씬의 로딩이 완료된 후 데이터 송수신을 재개하는 로직이 포함되어 있음

 

만약 수동으로 처리한다면 PhotonNetwork.IsMessageQueueRunning 속성을 false 로 지정하고 로딩된 씬에서 true

 

씬의 로딩은 마스터 클라이언트만 호출해야됨 

룸에 입장한 다른 네트워크 유저는 PhotonNetwork.AutomaticallySynScene 을 true로 설정했기 때문에 마스터 클라이언트가 다른 씬을 로딩하면 자동으로 씬이 로딩됨

public override void OnJoinedRoom()
{
    Debug.Log($"PhotonNetwork.InRoom = {PhotonNetwork.InRoom}");
    Debug.Log($"Player Count = {PhotonNetwork.CurrentRoom.PlayerCount}");

    foreach(var player in PhotonNetwork.CurrentRoom.Players)
    {
        Debug.Log($"{player.Value.NickName},{player.Value.ActorNumber}");
    }

    // 출현 위치 정보를 배열에 저장
    //Transform[] points = GameObject.Find("SpawnPointGroup").GetComponentsInChildren<Transform>();
    //int idx = Random.Range(1, points.Length);

    // 네트워크상에 캐릭터 생성
    //PhotonNetwork.Instantiate("Player", points[idx].position, points[idx].rotation, 0);

    // 마스터 클라이언트인 경우에 룸에 입장한 후 전투 씬을 로딩한다
    if(PhotonNetwork.IsMasterClient)
    {
        PhotonNetwork.LoadLevel("BattleField");
    }
}

 

OnLoginClick 함수는 로그인 UI의 Login 버튼 클릭 이벤트에 연결할 함수

OnMakeRoomClick 함수는 로그인 UI의 Make Room 버튼에 연결할 함수

 

두 함수는 버튼 클릭 이벤트에 연결될 이벤트 함수이자 사용자 정의 함수임
개발자가 정의한 일반 함수와 성격이 다른 이벤트 함수의 경우 항상 접두사 On을 붙여주는 것이 좋은 코딩 습관

 

 

두 버튼도 이벤트에 함수를 연결한다.

 

게임 룸 입장

실행하면 유저명과 룸에 접속한 접속자 수가 표시되고 BattleField 씬으로 넘어옴

 

주인공 캐릭터를 생성하는 로직 작성해야됨

GameManager.cs를 새로 생성하고 아래와 같이 작성

더보기
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;

public class GameManager : MonoBehaviour
{
    private void Awake()
    {
        CreatePlayer();
    }

    void CreatePlayer()
    {
        // 출현 위치 정보를 배열에 저장
        Transform[] points = GameObject.Find("SpawnPointGroup").GetComponentsInChildren<Transform>();
        int idx = Random.Range(1, points.Length);

        // 네트워크상에 캐릭터 생성
        PhotonNetwork.Instantiate("Player",
                                    points[idx].position,
                                    points[idx].rotation,
                                    0);
    }
}

GameManager 오브젝트를 새로 생성하고 연결

 

룸 목록 UI 구현

 두번째로 로그인한 유저의 룸 접속 방식은 랜덤 매치 메이킹을 통해 이미 생성된 룸에 자동으로 입장하는 것이지만

이미 만들어진 룸의 목록을 조회하고 그중에서 룸을 선택하여 입장할 수 있도록 해보기

 

Scroll Rect 컴포넌트

Panel-Login 하위에 새로운 패널인 Panel-RoomList를 새로 생성하고 하위에 ScrollView 오브젝트를 새로 추가한다.

ScrollView 컴포넌트의 Horizontal 속성은 언체크한다.

스크롤 객체 생성

로비에 접속했을 때 포톤 클라우드 서버에서 보내주는 룸 목록을 수신받아 동적으로 구성해야하기 때문에 UI 를 디자인 하는 시점에서 생성될 룸의 개수가 몇 개인지 알 수 없으며 가변적임 

 

룸 정보를 표시하는 UI 프리팹으로 미리 만들고 로비에 접속했을 때 수신된 룸 개수만큼 반복문으로 반복하며 동적으로 룸 정보를 표시함 

Scroll View - Viewport - Contents 하위에 생성하는 방식으로 룸 목록을 구현

Contents 하위에 이미지 생성하고 이름은 RoomItem으로 변경하고 Source Image 속성은 Background로 설정 

하위에 Text(TMP) 오브젝트를 새로 추가하고 RoomItem 이미지에 Button 컴포넌트를 추가 후 적절히 디자인을 조절함

Grid Layout Group 컴포넌트

RoomItem을 프리팹으로 만들고 Layout 계열의 컴포넌트를 이용하여 정렬해보기 Resources 폴더로 옮김

Content 오브젝트에 Grid Layout Group 컴포넌트를 추가 후 Cell Size 만 빼고 똑같이 조절한 후 가로길이에 맞춰서 CellSize를 조절하도록 한다.

 

룸 목록 받아오기

PhotonManager.cs 스크립트에 아래 함수를 새로 추가하

// 룸 목록을 수신하는 콜백 함수
public override void OnRoomListUpdate(List<RoomInfo> roomList)
{
    foreach(var room in roomList)
    {
        // room.ToString()
        Debug.Log($"Room={room.Name} ({room.PlayerCount} / {room.MaxPlayers})");
    }
}

 

룸의 정보 변화가 발생할때마다 콜백 함수가 호출된다. 

삭제된 룸에 대한 정보도 넘어오게 되는데 이는 RemovedFromList 속성으로 확인 가능함

룸 목록을 Dictionary 타입의 자료형으로 관리하고 PhotonManager.cs 를 아래와 같이 수정한다.

더보기
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using TMPro;


public class PhotonManager : MonoBehaviourPunCallbacks
{
    // 게임의 버전
    private readonly string version = "1.0";
    // 유저의 닉네임
    private string userId = "Zark";

    // 유저명을 입력할 TExtMEshPRo Input Field
    public TMP_InputField userIF;
    // 룸 이름을 입력할 TextMEshPro Input Field   
    public TMP_InputField roomNameIF;

    // 룸 목록에 대한 데이터를 저장하기 위한 딕셔너리 자료형
    private Dictionary<string, GameObject> rooms = new Dictionary<string, GameObject>();
    // 룸 목록을 표시할 프리팹
    private GameObject roomItemPrefab;
    // RoomItem 프리팹이 추가될 ScrollContent
    public Transform scrollContent;

    private void Awake()
    {
        // 마스터 클라이언트의 씬 자동 동기화 옵션
        PhotonNetwork.AutomaticallySyncScene = true;
        // 게임 버전 설정
        PhotonNetwork.GameVersion = version;
        // 접속 유저의 닉네임 설정
        // PhotonNetwork.NickName = userId;

        // 포톤 서버와의 데이터의 초당 전송 횟수
        Debug.Log(PhotonNetwork.SendRate);

        // RoomItem 프리팹 로드
        roomItemPrefab = Resources.Load<GameObject>("RoomItem");

        // 포톤 서버 접속 
        PhotonNetwork.ConnectUsingSettings();
    }

    private void Start()
    {
        // 저장된 유저명을 로드
        userId = PlayerPrefs.GetString("USER_ID", $"USER_{Random.Range(1, 21):00}");
        userIF.text = userId;
        // 접속 유저의 닉네임 등록
        PhotonNetwork.NickName = userId;
    }
    /// <summary>
    /// 유저명을 설정하는 로직
    /// </summary>
    public void SetUserId()
    {
        if(string.IsNullOrEmpty(userIF.text))
        {
            userId = $"USER_{Random.Range(1, 21):00}";
        }
        else
        {
            userId = userIF.text;
        }

        // 유저명 저장
        PlayerPrefs.SetString("USER_ID", userId);
        // 접속 유저의 닉네임 등록
        PhotonNetwork.NickName = userId;
    }

    /// <summary>
    /// 룸 명의 입력 여부를 확인하는 로직
    /// </summary>
    string SetRoomName()
    {
        if(string.IsNullOrEmpty(roomNameIF.text))
        {
            roomNameIF.text = $"ROOM_{Random.Range(1, 101):000}";
        }
        return roomNameIF.text;
    }

    /// <summary>
    /// 포톤 서버에 접속 후 호출되는 콜백 함수
    /// </summary>
    public override void OnConnectedToMaster()
    {
        Debug.Log("Connected to Master!");
        Debug.Log($"PhotonNetwork.InLobby = {PhotonNetwork.InLobby}");
        PhotonNetwork.JoinLobby();
    }

    /// <summary>
    /// 로비에 접속 후 호출되는 콜백 함수
    /// </summary>
    public override void OnJoinedLobby()
    {
        Debug.Log($"PhotonNetwork.InLobby = {PhotonNetwork.InLobby}");
        // 수동으로 저복하기 위해 자동 입장은 주석 처리
        // PhotonNetwork.JoinRandomRoom();
    }

    /// <summary>
    /// 랜덤한 룸 입장이 실패했을 경우 호출되는 콜백 함수
    /// </summary>
    /// <param name="returnCode"></param>
    /// <param name="message"></param>
    public override void OnJoinRandomFailed(short returnCode, string message)
    {
        Debug.Log($"JoinRandom Failed {returnCode}:{message}");
        OnMakeRoomClick();

        // 룸의 속성 정의
        //RoomOptions ro = new RoomOptions();
        //ro.MaxPlayers = 20;     // 룸에 입장할 수 있는 최대 접속자 수
        //ro.IsOpen = true;       // 룸의 오픈 여부
        //ro.IsVisible = true;    // 로비에서 룸 목록에 노출시킬지 여부

        // 룸 생성
        //PhotonNetwork.CreateRoom("My Room", ro);
    }
    // 룸 생성이 완료된 후 호출되는 콜백 함수
    public override void OnCreatedRoom()
    {
        Debug.Log("Created Room");
        Debug.Log($"Room Name = {PhotonNetwork.CurrentRoom.Name}");
    }

    // 룸 생성이 완료 된 후 호출되는 콜백 함수
    public override void OnJoinedRoom()
    {
        Debug.Log($"PhotonNetwork.InRoom = {PhotonNetwork.InRoom}");
        Debug.Log($"Player Count = {PhotonNetwork.CurrentRoom.PlayerCount}");

        foreach(var player in PhotonNetwork.CurrentRoom.Players)
        {
            Debug.Log($"{player.Value.NickName},{player.Value.ActorNumber}");
        }

        // 출현 위치 정보를 배열에 저장
        //Transform[] points = GameObject.Find("SpawnPointGroup").GetComponentsInChildren<Transform>();
        //int idx = Random.Range(1, points.Length);

        // 네트워크상에 캐릭터 생성
        //PhotonNetwork.Instantiate("Player", points[idx].position, points[idx].rotation, 0);

        // 마스터 클라이언트인 경우에 룸에 입장한 후 전투 씬을 로딩한다
        if(PhotonNetwork.IsMasterClient)
        {
            PhotonNetwork.LoadLevel("BattleField");
        }
    }

    /// <summary>
    /// 룸 목록을 수신하는 콜백 함수
    /// </summary>
    /// <param name="roomList"></param>
    public override void OnRoomListUpdate(List<RoomInfo> roomList)
    {
        // 삭제된 RoomItem 프리팹을 저장할 임시변수
        GameObject tempRoom = null;

        foreach(var roomInfo in roomList)
        {
            // 룸이 삭제된 경우
            if(roomInfo.RemovedFromList == true)
            {
                // 딕셔너리에서 룸 이름으로 검색해 저장된 RoomItem 프리팹을 추출
                rooms.TryGetValue(roomInfo.Name, out tempRoom);

                // RoomItem 프리팹 삭제
                Destroy(tempRoom);

                // 딕셔너리에서 해당 룸 이름의 데이터를 삭제
                rooms.Remove(roomInfo.Name);
            }
            else    // 룸 정보가 변경된 경우
            {
                // 룸 이름이 딕셔너리에 없는 경우 새로 추가
                if(rooms.ContainsKey(roomInfo.Name)== false)
                {
                    // RoomInfo 프리팹을 scrollContent 하위에 생성
                    GameObject roomPrefab = Instantiate(roomItemPrefab, scrollContent);
                    // 룸 정보를 표시하기 위해 RoomInfo 정보 전달
                    roomPrefab.GetComponent<RoomData>().RoomInfo = roomInfo;

                    // 딕셔너리 자료형에 데이터 추가
                    rooms.Add(roomInfo.Name, roomPrefab);
                }
                else // 룸 이름이 딕셔너리에 없는 경우에 룸 정보를 갱신
                {
                    rooms.TryGetValue(roomInfo.Name, out tempRoom);
                    tempRoom.GetComponent<RoomData>().RoomInfo = roomInfo;
                }
            }
            Debug.Log($"Room={roomInfo.Name} ({roomInfo.PlayerCount} / {roomInfo.MaxPlayers})");
        }
    }

    #region UI_BUTTON_EVENT
    public void OnLoginClick()
    {
        // 유저명 저장
        SetUserId();

        // 무작위로 추출한 룸으로 입장
        PhotonNetwork.JoinRandomRoom();
    }
    public void OnMakeRoomClick()
    {
        // 유저명 저장
        SetUserId();

        // 룸의 속성 정의
        RoomOptions ro = new RoomOptions();
        ro.MaxPlayers = 20;     // 룸에 입장할 수 있는 최대 접속자 수
        ro.IsOpen = true;       // 룸의 오픈 여부
        ro.IsVisible = true;    // 로비에서 룸 목록에 노출시킬지 여부

        // 룸 생성
        PhotonNetwork.CreateRoom(SetRoomName(), ro);
    }

    #endregion
}

 

C#에서 Dictionary 를 사용하기 위해 System.Colledtions.Generic 네임 스페이스를 선언함

using System.Collections.Generic;

버튼 이벤트 동적 연결

버튼을 클릭했을 때 이벤트에서 룸에 접속하기 위해 새로운 스크립으 RoomData 생성 하고 RoomItem 프리팹에 추가함

더보기
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using TMPro;

public class RoomData : MonoBehaviour
{
    private RoomInfo _roomInfo;
    // 하위에 있는 TMP_Text 를 저장할 변수
    private TMP_Text roomInfoText;
    // PhotonManager 접근 변수
    private PhotonManager photonManager;

    // 프로퍼티 정의
    public RoomInfo RoomInfo
    {
        get
        {
            return _roomInfo;
        }
        set
        {
            _roomInfo = value;
            // 룸 정보 표시
            roomInfoText.text = $"{_roomInfo.Name} ({_roomInfo.PlayerCount} / {_roomInfo.MaxPlayers})";
            // 버튼 클릭 이벤트 함수에 연결
            GetComponent<UnityEngine.UI.Button>().onClick.AddListener(() => OnEnterRoom(_roomInfo.Name));
        }
    }

    private void Awake()
    {
        roomInfoText = GetComponentInChildren<TMP_Text>();
        photonManager = GameObject.Find("PhotonManager").GetComponent<PhotonManager>();
    }
    void OnEnterRoom(string roomName)
    {
        // 유저명 설정
        photonManager.SetUserId();

        // 룸의 속성 정의
        RoomOptions ro = new RoomOptions();
        ro.MaxPlayers = 20;
        ro.IsOpen = true;
        ro.IsVisible = true;
        // 룸 접속
        PhotonNetwork.JoinOrCreateRoom(roomName, ro, TypedLobby.Default);
    }
}

RoomData 스크립트는 OnRoomisUpdate 에서 룸 정보가 갱신될 때마다 접근해 RoomInfo 데이터를 넘겨 받아서 내부적으로 저장하고 하위에 있는 텍스트 UI에 룸 이름과 접속자 정보를 표시함

버튼을 클릭했을 때 룸에 접속하는 함수를 람다식으로 연결함