[Unity] Photon Matchmaking

limce·2025년 4월 11일

Unity

목록 보기
4/6
post-thumbnail

Photon Matchmaking

Photon Pun2를 이용하여 2인 멀티 플레이 게임을 제작하고 있다.
Photon을 이용하여 매치메이킹을 구현하고자 한다.

매치 방식은 2가지다.

  • 프라이빗 매치(친구와 함께 플레이)
  • 랜덤 매치

프라이빗 매치

한 명이 초대 코드를 생성하면 다른 한 명이 초대 코드를 입력하여 같은 방에 입장할 수 있다.

  • 초대 코드 생성, 새 방 생성 및 방 이름을 초대 코드로 설정하여 입장
  • 초대 코드 입력 후 방 이름이 초대 코드인 방으로 입장

랜덤 매치

  • 인원이 차지 않은 방을 찾는다
  • 인원이 차지 않는 방이 있으면 해당 방으로 입장
  • 모든 방의 인원이 다 찼다면 새 방 생성 후 입장

전체 코드

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

public class MatchManager : MonoBehaviourPunCallbacks
{
    [Header("UI")]
    public TMP_InputField joinCodeInputField;
    public TMP_Text statusText;

    private const int MaxPlayer = 2;
    private const int MaxRetry = 3;
    private const int RoomCodeLen = 6;

    private static readonly char[] RoomCodeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".ToCharArray();
    
    void Awake()
    {
        // 자동 씬 동기화
        PhotonNetwork.AutomaticallySyncScene = true;
    }

    void Start()
    {
        if (!PhotonNetwork.IsConnected)
        {
            PhotonNetwork.ConnectUsingSettings();
            statusText.text = "서버에 연결 중...";
        }
    }

    public override void OnConnectedToMaster()
    {
        PhotonNetwork.JoinLobby();
        statusText.text = "서버 연결 완료";
        Debug.Log("Connected to Master");
    }

    // 1. 친구와 함께하기
    public void OnClick_CreateRoom()
    {
        StartCoroutine(TryCreateRoom());
    }

    IEnumerator TryCreateRoom()
    {
        int retry = MaxRetry;

        while (retry > 0)
        {
            string code = GenerateRoomCode();
            RoomOptions options = new RoomOptions
            {
                MaxPlayers = MaxPlayer,
                IsVisible = false,
                IsOpen = true
            };

            PhotonNetwork.CreateRoom(code, options, TypedLobby.Default);
            statusText.text = $"방 생성 중... 코드 {code}";

            // Photon 응답 지연 시간
            float elapsed = 0f;
            const float timeout = 2f; // 테스트 후 조정 가능

            while (!PhotonNetwork.InRoom && elapsed < timeout)
            {
                elapsed += Time.deltaTime;
                yield return null;
            }

            if (PhotonNetwork.InRoom)
            {
                yield break;
            }

            retry--;
        }

        statusText.text = "방 생성 실패. 다시 시도해주세요.";
    }

    public void OnClick_JoinWithCode()
    {
        string code = joinCodeInputField.text.ToUpper();

        if (code.Length != RoomCodeLen)
        {
            statusText.text = "코드는 6자리여야 합니다.";
            return;
        }

        PhotonNetwork.JoinRoom(code);
        statusText.text = $"{code} 방에 입장 중...";
    }

    private string GenerateRoomCode()
    {
        System.Text.StringBuilder code = new System.Text.StringBuilder();

        for (int i = 0; i < RoomCodeLen; i++)
        {
            int index = Random.Range(0, RoomCodeChars.Length);
            code.Append(RoomCodeChars[index]);
        }

        return code.ToString();
    }

    public override void OnJoinRoomFailed(short returnCode, string message)
    {
        statusText.text = "초대 코드가 잘못 되었거나 방이 꽉 찼어요!";
        Debug.LogWarning($"JoinRoom 실패: {message}");
    }

    // 2.랜덤 매치
    public void OnClick_RandomMatch()
    {
        PhotonNetwork.JoinRandomRoom();
        statusText.text = "랜덤 매칭 중...";
    }

    public override void OnJoinRandomFailed(short returnCode, string message)
    {
        RoomOptions options = new RoomOptions
        {
            MaxPlayers = MaxPlayer,
            IsVisible = true,
            IsOpen = true
        };

        PhotonNetwork.CreateRoom(null, options);
        statusText.text = "새 방 생성 중...";
    }

    public override void OnJoinedRoom()
    {
        statusText.text = $"방 입장. 현재 인원: {PhotonNetwork.CurrentRoom.PlayerCount}/{MaxPlayer}";

        if (PhotonNetwork.CurrentRoom.PlayerCount == MaxPlayer)
        {
            Debug.Log("모든 플레이어 입장 완료. 게임 시작");
            // 두 플레이어가 캐릭터 선택 씬으로 이동
            PhotonNetwork.LoadLevel("Character");
        }
    }

    // 연결 실패 시
    public override void OnDisconnected(DisconnectCause cause)
    {
        statusText.text = "서버 연결 끊김: " + cause.ToString();
    }
}
  • 전체 코드는 다음과 같다. 좀 더 세부적으로 살펴보자.

네임스페이스와 멤버 변수

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

using Photon.Pun;, using Photon.Realtime;
Photon Unity Networking (PUN) 관련 기능을 가져다 쓰기 위한 네임스페이스

using Photon.Pun;

  • PUN의 기능 전반(핵심 기능들)을 담고 있는 네임스페이스
  • PhotonNetwork, MonoBehaviourPunCallbacks, PhotonView 사용 가능

using Photon.Realtime;

  • 좀 더 로우레벨의 네트워크 관련 기능을 제공하는 네임스페이스
  • 방 관련 세부 설정(RoomOptions), 로비 시스템 (TypedLobby), 매치 결과 처리 등에서 사용 가능
	public TMP_InputField joinCodeInputField;
    public TMP_Text statusText;

    private const int MaxPlayer = 2;
    private const int MaxRetry = 3;
    private const int RoomCodeLen = 6;

    private static readonly char[] RoomCodeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".ToCharArray();
  • joinCodeInputField: 초대 코드를 입력 받을 TMP_InputField

  • statusText: 현재 상태(서버 연결, 방 입장 등)를 나타낼 TMP_Text

  • MaxPlayer: 최대 입장 가능한 플레이어 수

  • MaxRetry: 최대 방 생성 시도 횟수

  • RoomCodeLen: 초대 코드 길이

  • RoomCodeChars: 초대 코드에 들어갈 수 있는 영문자와 숫자 배열

    • 영문자 O, I와 숫자 0, 1과의 혼동을 막기 위해 배열에는 제외했다.
      (실제로 모동숲도 초대코드에 이 4가지를 제외하고 있어 여기서 아이디어를 얻었다.)
    • static readonly: 이 필드는 선언된 이후에는 값을 변경할 수 없다.
      • static: 이 배열은 바뀌지 않고 인스턴스마다 다를 필요도 없기 때문에(공용 데이터), 클래스 전체에서 공유하는 정적인 데이터로 선언했다.
      • readonly: 한번 설정되면 바꾸지 않는 불변 데이터

readonly와 const의 차이점

readonly(읽기 전용 필드)

  • 런타임에 값이 결정될 수 있다.
  • 생성자에서 초기화 가능
  • 값이 고정되긴 하나, 객체를 만들 때 설정 가능
  • 이 코드에서는 ToCharArray()는 함수라서 런타임에 실행되기 때문에 readonly로 선언했다.

const

  • 컴파일 타임에 값이 결정된다.
  • 기본형 타입이나 문자열 같은 간단한 값에 주로 사용한다.
  • 선언할 때 반드시 값을 할당해야 한다.
  • 자동으로 static이다.

서버 연결

void Awake()
    {
        // 자동 씬 동기화
        PhotonNetwork.AutomaticallySyncScene = true;
    }

void Start()
    {
        if (!PhotonNetwork.IsConnected)
        {
            PhotonNetwork.ConnectUsingSettings();
            statusText.text = "서버에 연결 중...";
        }
    }

Awake()

  • 씬 동기화 기능을 킨다.
  • 마스터 클라이언트가 씬을 바꿀 때, 방에 있는 다른 클라이언트들도 자동으로 똑같은 씬으로 이동한다.

Start()

  • 현재 Photon에 연결되어 있는지 확인 후
    아직 연결 안 됐으면 연결 시작

ConnectUsingSettings()

  • Photon Server에 연결하는 함수
public override void OnConnectedToMaster()
    {
        PhotonNetwork.JoinLobby();
        statusText.text = "서버 연결 완료";
        Debug.Log("Connected to Master");
    }
  • 서버에 연결이 완료되면 자동으로 호출되는 콜백 함수
  • 혹시 이 함수가 존재하지 않는다는 오류가 발생하면 MonoBehaviourPunCallbacks를 상속받았는지 확인하자

PhotonNetwork.JoinLobby()

  • 로비에 입장. 이후 방 리스트를 보거나 만들 수 있다.

프라이빗 매치

// 1. 친구와 함께하기
    public void OnClick_CreateRoom()
    {
        StartCoroutine(TryCreateRoom());
    }

    IEnumerator TryCreateRoom()
    {
        int retry = MaxRetry;

        while (retry > 0)
        {
            string code = GenerateRoomCode();
            RoomOptions options = new RoomOptions
            {
                MaxPlayers = MaxPlayer,
                IsVisible = false,
                IsOpen = true
            };

            PhotonNetwork.CreateRoom(code, options, TypedLobby.Default);
            statusText.text = $"방 생성 중... 코드 {code}";

            // Photon 응답 지연 시간
            float elapsed = 0f;
            const float timeout = 2f; // 테스트 후 조정 가능

            while (!PhotonNetwork.InRoom && elapsed < timeout)
            {
                elapsed += Time.deltaTime;
                yield return null;
            }

            if (PhotonNetwork.InRoom)
            {
                yield break;
            }

            retry--;
        }

        statusText.text = "방 생성 실패. 다시 시도해주세요.";
    }
  • 버튼을 누르면 TryCreateRoom 코루틴이 시작된다.

TryCreateRoom

  • 초대코드가 중복될 수 있음을 고려하여, 방 생성을 최대 3번(MaxRetry) 시도한다.
  • 방 옵션 MaxPlayers, IsVisible, IsOpen 설정 후 방 생성
    • MaxPlayers: 방에 들어올 수 있는최대 인원 수
    • IsVisible: 로비에서 다른 사람들이 볼 수 있는지
      • 이 코드에서는 false이므로 비공개 방이다. 초대나 특정 방 이름으로만 입장할 수 있다.
    • IsOpen: 방에 들어올 수 있는지
      • false이면 방에 인원이 더 들어올 수 있더라도 입장이 제한된다. (게임이 이미 시작된 경우 등)
  • CreateRoom(방 이름, 옵션, 로비 타입)
    • 방 생성을 성공하면 바로 해당 방으로 이동한다.
    • 옵션: MaxPlayers, IsOpen, IsVisible, CustomRoomProperties, CleanupCacheOnLeave, PlayerTtl 옵션이 존재한다.
    • TypedLobby: Default, SqlLobby, AsyncRandomLobby 로비타입이 존재한다.
  • !PhotonNetwork.InRoom
    • 방에 들어왔는지(방 생성이 성공했는지) 확인 후
      성공하면 코루틴 종료
      실패하면 누적 지연 시간을 더한 후, 매 프레임마다 다시 체크
  • elapsed < timeout
    • Photon은 비동기 네트워크이므로 CreateRoom() 호출 직후 응답이 오기까지 시간 차이가 생긴다.
      따라서 바로 다음 줄에 InRoom을 체크하면 아직 false일 확률이 높으므로 몇 초 기다린 후 방에 입장했는지 확인한다.
    • 2초 안에 InRoom이 true가 안 되면 방 생성 실패로 간주하고 다시 시도한다.
public void OnClick_JoinWithCode()
    {
        string code = joinCodeInputField.text.ToUpper();

        if (code.Length != RoomCodeLen)
        {
            statusText.text = "코드는 6자리여야 합니다.";
            return;
        }

        PhotonNetwork.JoinRoom(code);
        statusText.text = $"{code} 방에 입장 중...";
    }
  • 입력한 초대 코드의 영문자를 모두 대문자로 변환한다. (소문자로 입력한 경우에도 대문자로 취급하도록)
  • JoinRoom(code)
    • 초대 코드가 6자리이면 초대 코드를 이름으로 갖는 방을 찾아 접속을 시도한다.
    • 성공하면 OnJoinedRoom() 콜백 호출
    • 실패하면 OnJoinRoomFailed() 콜백 호출
private string GenerateRoomCode()
    {
        System.Text.StringBuilder code = new System.Text.StringBuilder();

        for (int i = 0; i < RoomCodeLen; i++)
        {
            int index = Random.Range(0, RoomCodeChars.Length);
            code.Append(RoomCodeChars[index]);
        }

        return code.ToString();
    }
  • 초대 코드 생성 함수

StringBuilder vs string

  • string은 불변 타입이기 때문에 +=로 문자열을 추가하려고 할 때마다, 실제로는 새로운 문자열이 계속 생성되고 기존 것은 버려진다.
    ➡️문자열을 많이 연결할수록 불필요한 메모리 할당과 복사가 많이 발생
  • StringBuilder는 내부 버퍼에서 계속 덧붙힌다.
    따라서 메모리를 한 번 할당하고 계속 그 안에서 작업한다.
  • 초대 코드를 생성할 때는 계속해서 문자를 덧붙여야 한다는 점을 고려하여 더 효율적인 StringBuilder를 사용했다.
public override void OnJoinRoomFailed(short returnCode, string message)
    {
        statusText.text = "초대 코드가 잘못 되었거나 방이 꽉 찼어요!";
        Debug.LogWarning($"JoinRoom 실패: {message}");
    }
  • JoinRoom이 실패하면 호출된다.
  • 뒤에 나오는 랜덤 매치에서도 방 입장을 실패하면 이 함수가 호출되는 건가? 했지만 그렇지 않다.
    뒤에서 보겠지만, 랜덤 매치에서는 입장할 때 JoinRandomRoom()을 사용하여 입장을 시도한다.
    JoinRandomRoom()을 통해 입장 실패 시 OnJoinRandomFailed이 호출된다.
    따라서 현재 코드에서는 JoinRoom 실패 시에만, 즉 초대 코드로의 입장 실패 시에만 호출된다.

랜덤 매치

// 2.랜덤 매치
    public void OnClick_RandomMatch()
    {
        PhotonNetwork.JoinRandomRoom();
        statusText.text = "랜덤 매칭 중...";
    }

JoinRandomRoom()

  • 조건 없이 랜덤한 공개 방에 자동으로 입장을 시도하는 함수
  • 성공 시 OnJoinedRoom() 호출
  • 실패 시 OnJoinRandomFailed() 호출
    public override void OnJoinRandomFailed(short returnCode, string message)
    {
        RoomOptions options = new RoomOptions
        {
            MaxPlayers = MaxPlayer,
            IsVisible = true,
            IsOpen = true
        };

        PhotonNetwork.CreateRoom(null, options);
        statusText.text = "새 방 생성 중...";
    }
  • 랜덤 방 입장 실패 후 호출되는 함수
  • 방 이름은 지정하지 않았다. 따로 초대 코드가 필요한 방도 아니고 추후 방 이름을 가져와야 하는 경우도 없어서 null로 만들어 주었다.
  • 최대 인원 2명, 공개된, 들어올 수 있는 방 생성
public override void OnJoinedRoom()
    {
        statusText.text = $"방 입장. 현재 인원: {PhotonNetwork.CurrentRoom.PlayerCount}/{MaxPlayer}";

        if (PhotonNetwork.CurrentRoom.PlayerCount == MaxPlayer)
        {
            Debug.Log("모든 플레이어 입장 완료. 게임 시작");
            // 두 플레이어가 캐릭터 선택 씬으로 이동
            PhotonNetwork.LoadLevel("Character");
        }
    }
  • 방 입장 시 호출되는 함수
  • 프라이빗 매치, 랜덤 매치 둘 중 어느 방식으로든 방 입장 시 호출된다.
  • 만약 현재 방에 있는 플레이어 수와 최대 플레이어 수가 같다면, 캐릭터 선택 씬으로 이동한다.
  • LoadScene이 아닌 반드시 LoadLevel 함수를 호출해주어야 함께 이동한다.
    마스터 클라이언트가 씬 변경 시, 자동으로 나머지 플레이어도 이동한다.
    LoadScene 사용 시 현재 플레이어만 씬을 이동하고 다른 클라이언트는 그대로 남아있다.
    • Awake나 Start에서 반드시
      PhotonNetwork.AutomaticallySyncScene = true;
      이 코드를 추가해주어야 한다.

0개의 댓글