# Playfab 브리지

이 가이드는 PlayFab의 매치메이커를 통해 플레이어들이 매칭된 후 Edgegap에 서버를 자동으로 배포하도록 게임을 설정하는 방법을 보여줍니다. 클라이언트는 서버가 준비되면 서버가 업데이트하는 PlayFab 플레이어 데이터를 통해 서버에 연결하는 정보를 액세스합니다. 시작하기 전에 다음을 전제로 합니다:

* 현재 PlayFab을 사용하고 있습니다;
* 이미 [생성했습니다](/docs.edgegap.com-ko/learn/orchestration/application-and-versions.md) 및 [배포했습니다](/docs.edgegap.com-ko/learn/orchestration.md) 이전에 Edgegap로 애플리케이션을 배포한 적이 있습니다.

최종 샘플 프로젝트는 우리의 [GitHub](https://github.com/edgegap/sample-playfab-matchmaker-fishnet)에서 찾을 수 있습니다. 이 샘플은 Unity v2021.3.10 및 네트코드로 Fishnet v3.11.14를 사용하여 테스트되었습니다.

### PlayFab Cloud Script 설정

#### 새 함수 생성

PlayFab 계정에 로그인한 다음 대시보드에서 게임의 개요 페이지로 이동합니다.  `Automation` 탭에서 Edgegap API로 요청을 보내 새 서버를 배포할 Cloud Script 함수를 새로 만듭니다.

요청에 플레이어들의 위도/경도 위치 목록을 전달해야 하며, 그래야 서버를 모두에게 가장 적절한 위치에 배포할 수 있습니다.

또한 키-값 쌍으로 몇 가지 사용자 지정 환경 변수를 전달해야 합니다. 즉:

* 다음 `Match ID` 는 PlayFab의 매치메이커에서 생성됩니다;
* PlayFab `Title Secret Key`는 타이틀 설정에서 찾을 수 있습니다;
* 다음의 `목록` 각 플레이어의 `Master Player Account Id`.

이 변수들은 플레이어들의 PlayFab `Player Data` 를 서버에 연결하는 데 필요한 정보로 업데이트하는 데 사용됩니다. 이러한 변수를 설정할 때 사용하는 키를 기록해 두십시오. 이 샘플에서는 다음과 같습니다. `"PLAYFAB_MATCH_ID"`, `"PLAYFAB_TITLE_SECRET"`및 `"PLAYFAB_MATCH_PLAYERS_ID_LIST"`.

{% hint style="info" %}
이 함수를 만드는 가장 간단한 방법은 `Revisions (Legacy)` 탭에서 새 리비전을 배포하고 다음 코드를 추가하는 것입니다. 먼저 최상단의 `Edgegap_AppName`, `Edgegap_AppVersion`, `Edgegap_ApiToken` 및 `PlayFab_TitleSecret` 변수를 자신의 값으로 업데이트했는지 확인하십시오.

앱 이름과 버전은 Edgegap에서 게임 서버를 컨테이너화할 때 나중에 사용됩니다.

플레이어 ID 목록은 각 ID가 쉼표로 구분된 하나의 문자열로 여기 저장됩니다.
{% endhint %}

```javascript
var Edgegap_AppName = "APP_NAME";
var Edgegap_AppVersion = "APP_VERSION";
var Edgegap_ApiToken = "token XXXX"; // "token" 키워드를 포함하세요
var PlayFab_TitleSecret = "TITLE_SECRET";

handlers.StartEdgegapDeployment = function (args, context) {
  var QueueName = context.playStreamEvent.Payload.QueueName;
  var MatchId = context.playStreamEvent.Payload.MatchId;

  var GeoIpList = [];
  var PlayerIds = "";

  var MatchMember = multiplayer.GetMatch({
    QueueName: QueueName,
    MatchId: MatchId,
    EscapeObject: false,
    ReturnMemberAttributes: false,
  }).Members;

  MatchMember.forEach((Member) => {
    var Profile = entity.GetProfile({
      Entity: Member.Entity,
    });

    var Location = server.GetPlayerProfile({
      PlayFabId: Profile.Profile.Lineage.MasterPlayerAccountId,
      ProfileConstraints: {
        ShowLocations: true,
      },
    });

    GeoIpList.push({
      ip: "1.2.3.4",
      latitude: Location.PlayerProfile.Locations[0].Latitude,
      longitude: Location.PlayerProfile.Locations[0].Longitude,
    });

    PlayerIds += Profile.Profile.Lineage.MasterPlayerAccountId + ",";
  });

  PlayerIds = PlayerIds.slice(0, -1);

  var response = JSON.parse(
    http.request(
      "https://api.edgegap.com/v1/deploy",
      "post",
      JSON.stringify({
        app_name: Edgegap_AppName,
        version_name: Edgegap_AppVersion,
        geo_ip_list: [...GeoIpList],
        tags: [MatchId.slice(0, 20)],
        env_vars: [
          {
            key: "PLAYFAB_MATCH_ID",
            value: MatchId,
          },
          {
            key: "PLAYFAB_TITLE_SECRET",
            value: TitleSecret,
          },
          {
            key: "PLAYFAB_MATCH_PLAYERS_ID_LIST",
            value: PlayerIds,
          },
        ],
      }),
      "application/json",
      { authorization: Edgegap_ApiToken }
    )
  );

  log.info("response", response);
};
```

{% hint style="info" %}
이 Cloud Script 함수는 게임 클라이언트와 Edgegap API 사이의 인터페이스를 생성하여 귀하의 `Edgegap API 토큰` 및 `PlayFab Title Secret` 을(를) 플레이어로부터 숨겨 줍니다.
{% endhint %}

### 자동화 규칙

아직 `Automation`에서, `Rules` 탭으로 이동합니다. 다음 설정으로 새 규칙을 만드십시오:

* 다음을 설정하십시오: `Event Type` 에서 `playfab.matchmaking.match_found`;
* 새 `Action`:
  * 다음을 설정하십시오: `Type` 에서 `Execute Entity Cloud Script`;
  * 다음을 설정하십시오: `Cloud Script Function` 에서 `StartEdgegapDeployment`.

<figure><img src="/files/2ca421a4381a354b4f4c7f36ca05d83d93ecdafa" alt=""><figcaption></figcaption></figure>

### 매치메이커 큐 및 플레이어

&#x20; `Multiplayer` 탭에서, `Matchmaking`아래에 큐가 사용 가능하도록 설정되어 있는지 확인하거나, 그렇지 않다면 새 큐를 생성하십시오. 빠른 테스트를 위해 단순히 임의의 두 플레이어를 매칭하도록 설정하십시오.

매치메이커 규칙을 만족시킬 만큼 충분한 플레이어가 PlayFab 타이틀에 등록되어 있어야 하므로, 이 경우 두 명이면 충분합니다. 필요한 경우 `Players` 탭에서 새 플레이어를 생성할 수 있습니다. 이 샘플 프로젝트의 경우 나중에 테스트를 위해 해당 플레이어들의 `Custom ID` 및 `Title Player Account ID` 를 기록해 두십시오.

### 게임 편집

#### 서버

Unity 에디터에서 게임 샘플을 연 후, 우리는 `BattleScene` 에 새 빈 gameObject를 추가했습니다. 해당 씬은 `Assets/SpaceEdge/Scenes`에 있습니다. 그런 다음 `PlayerDataUpdateService` 라는 새 C# 스크립트를 이것에 연결했습니다. 이 스크립트에는 서버가 준비되면 실행될 콜백 함수가 포함되어 있습니다.

또한 PlayFab의 다양한 API에 전송해야 하는 여러 요청을 포함하는 `PlayfabRestApiService`라는 두 번째 스크립트도 만들었습니다.

파일 `PlayerDataUpdateService.cs`에서 배포 시 주입되는 몇 가지 환경 변수를 가져옵니다:

* 사용자 지정 변수:
  * `PLAYFAB_MATCH_ID`;
  * `PLAYFAB_TITLE_SECRET`;
  * `PLAYFAB_MATCH_PLAYERS_ID_LIST`
* 기본 Edgegap 변수:
  * `ARBITRIUM_PUBLIC_IP`;
  * `ARBITRIUM_PORTS_MAPPING`;

파일 `ARBITRIUM_PORTS_MAPPING`에서, 클라이언트가 서버에 연결할 수 있도록 하는 특정 외부 `포트` 값을 가져와야 합니다. 이 샘플에서는 이름이 `"Game Port"`.

인 포트입니다. `PLAYFAB_TITLE_SECRET` 인증으로 `Player Data` 를 사용하여 PlayFab 서버 API에 요청을 보내 해당 ID에 대한 `PLAYFAB_MATCH_PLAYERS_ID_LIST` 를 다음 키-값 쌍으로 업데이트합니다:

* 키: `PLAYFAB_MATCH_ID`;
* 값: `"{ARBITRIUM_PUBLIC_IP}:{port}"`;

#### PlayerDataUpdateService

```cs
public class PlayerDataUpdateService : NetworkBehaviour
{
    private PlayfabRestApiService _playfabService;

    [SerializeField] private string KeyMatchId = "PLAYFAB_MATCH_ID";
    [SerializeField] private string KeyTitleSecret = "PLAYFAB_TITLE_SECRET";
    [SerializeField] private string KeyPlayerIds = "PLAYFAB_MATCH_PLAYERS_ID_LIST";
    [SerializeField] private string ServerPortName = "Game Port";

    private string matchID;
    private string titleSecretKey;
    private string[] playerIdList;

    public override void OnStartServer()
    {
        _playfabService = FindObjectOfType<PlayfabRestApiService>();

        matchID = Environment.GetEnvironmentVariable(KeyMatchId);
        titleSecretKey = Environment.GetEnvironmentVariable(KeyTitleSecret);
        playerIdList = Environment.GetEnvironmentVariable(KeyPlayerIds)?.Split(',');

        string address = Environment.GetEnvironmentVariable("ARBITRIUM_PUBLIC_IP");
        string portsMapping = Environment.GetEnvironmentVariable("ARBITRIUM_PORTS_MAPPING");

        if (!string.IsNullOrEmpty(portsMapping))
        {
            int port = JSON.ParseString(portsMapping).GetJSON("ports").GetJSON(ServerPortName).GetInt("external");

            foreach (string playerId in playerIdList)
            {
                if (!string.IsNullOrEmpty(playerId))
                {
                    _playfabService.UpdatePlayerData(titleSecretKey, playerId, matchID, $"{address}:{port}");
                }
            }
        }
        else
        {
            Debug.Log("Port Mapping Unavailable");
        }
    }

    ...
}
```

#### PlayfabRestApiService

```cs
public class PlayfabRestApiService : MonoBehaviour
{
    ...

    [SerializeField] private string TitleID = "TITLE_ID";

    private const string TypeHeaderValue = "application/json";
    private readonly HttpClient _playfabHttpClient = new();
    private HttpResponseMessage _request;
    private StringContent _requestData;
    private string _response;

    ...

    public async void UpdatePlayerData(string secretKey, string playerID, string key, string value)
    {
        if (!_playfabHttpClient.DefaultRequestHeaders.Contains("X-SecretKey"))
        {
            _playfabHttpClient.DefaultRequestHeaders.Add("X-SecretKey", secretKey);
        }

        var dataProperty = new JSON();
        dataProperty.Add(key, value);

        var requestJson = new JSON();
        requestJson.Add("PlayFabId", playerID);
        requestJson.Add("Data", dataProperty);

        _requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);
        _request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Server/UpdateUserData", _requestData);
        _response = await _request.Content.ReadAsStringAsync();

        if (!_request.IsSuccessStatusCode)
        {
            Debug.Log($"Could Not Update PlayerData of ID {playerID}, Error {(int)_request.StatusCode} With Message: \n{_response}");
        }
    }
}
```

#### 클라이언트

클라이언트 측 설정은 `MainMenu` 씬에서 관리되며, 해당 씬은 `Assets/SpaceEdge/Scenes`에 있습니다. 우리는 `MainMenuService` 씬의 UI를 관리하고 플레이어에게 다양한 메시지를 표시하기 위해, 그리고 다음에 새로운 기능을 추가했습니다 `PlayfabRestApiService.cs`. 우리는 다음을 부착했습니다 `PlayfabRestApiService` 스크립트를 이 씬의 새 게임 오브젝트에도 붙였습니다.

플레이어가 가장 먼저 해야 할 일은 PlayFab으로 로그인하는 것입니다. 그러면 PlayFab의 Client 및 Multiplayer API에 추가 요청을 보낼 수 있습니다.

{% hint style="info" %}
우리는 다음을 사용합니다 `LoginWithCustomID` 엔드포인트를 단순화를 위해 사용하지만, 상황에 따라(예: 모바일 게임) 로그인 구현에 더 적합한 방법이 있습니다.
{% endhint %}

플레이어가 로그인하고 `X-Authorization` 및 `X-EntityToken` 헤더가 이후 요청을 위해 올바르게 설정되면, 다음을 사용하여 새로운 매치메이킹 티켓을 생성할 수 있습니다 `Start Game` UI 버튼. 그러면 PlayFab의 멀티플레이어 API에 요청을 보내고 `티켓 ID` 를 추후 사용을 위해 저장합니다.

그 후, 게임 클라이언트는 몇 초마다 티켓의 상태를 주기적으로 확인하여 매치가 발견되거나 티켓이 취소될 때까지 계속합니다. 매치가 발견되면 `매치 ID` 가 변수로 저장됩니다.

그 다음 클라이언트는 플레이어의 Title `Player Data`를 가져오기 위해 PlayFab의 Client API에 주기적으로 요청을 보냅니다. 이때 가져와야 하는 특정 데이터의 키로 `매치 ID` 를 사용합니다. 데이터가 발견되면 서버의 `주소` 와 외부 `포트` 값을 넷코드의 `트랜스포트` 컴포넌트에 설정한 다음 서버에 연결합니다.

클라이언트가 정상적으로 연결되고 시작되면, 해당 플레이어의 PlayFab에서 연결 정보를 제거하기 위한 요청이 전송됩니다 `Player Data` 콜백 함수 덕분에.

{% hint style="info" %}
이러한 키-값 쌍이 삭제되었는지 확인하는 것이 중요하며, 가능하면 배포가 종료되기 전에 삭제하는 것이 좋습니다. 이는 다음을 어질러 놓는 것을 방지하기 위함입니다 `Player Data` 에 불필요한 항목이 너무 많아지는 것을.
{% endhint %}

#### MainMenuService

```cs
public class MainMenuService : MonoBehaviour
{
    [SerializeField] private Button startGameButton;
    [SerializeField] private TMP_InputField playerNameInput;
    [SerializeField] private TMP_Text serverStatus;

    private PlayfabRestApiService _playfabService;

    private void Start()
    {
        if (!InstanceFinder.ServerManager.GetStartOnHeadless())
        {
            _playfabService = FindObjectOfType<PlayfabRestApiService>();
            _playfabService.OnStatusUpdate += ServerStatusUpdate;
            _playfabService.OnUpdateButton += UpdateButtonState;

            startGameButton.onClick.AddListener(StartGame);
            playerNameInput.onValueChanged.AddListener(NameChanged);
            if (PlayerPrefs.HasKey("PlayerName"))
                playerNameInput.text = PlayerPrefs.GetString("PlayerName");

            _playfabService.LoginPlayer();
        }
    }

    private void OnDestroy()
    {
        if (!InstanceFinder.ServerManager.GetStartOnHeadless())
        {
            _playfabService.OnStatusUpdate -= ServerStatusUpdate;
            _playfabService.OnUpdateButton -= UpdateButtonState;
        }
    }

    private void StartGame()
    {
        startGameButton.interactable = false;
        serverStatus.text = "";
        _playfabService.CreateTicket();
    }

    private void ServerStatusUpdate(string status, bool isError)
    {
        serverStatus.text += "\n" + status;
        serverStatus.color = isError ? Color.red : Color.green;
        Debug.Log(status);
    }

    private void UpdateButtonState(bool state)
    {
        startGameButton.interactable = state;
        serverStatus.text = "";
    }

    private void NameChanged(string text) => PlayerPrefs.SetString("PlayerName", text);
}
```

#### PlayfabRestApiService

```cs
public class PlayfabRestApiService : MonoBehaviour
{
    [SerializeField] private string PlayerCustomID = "PLAYER_CUSTOM_ID";
    [SerializeField] private string PlayerTitleID = "PLAYER_TITLE_ID";
    [SerializeField] private string TitleID = "TITLE_ID";
    [SerializeField] private string QueueName = "QUEUE_NAME";

    private const string TypeHeaderValue = "application/json";
    private readonly HttpClient _playfabHttpClient = new();
    private HttpResponseMessage _request;
    private StringContent _requestData;
    private string _response;
    private string _ticketID = "";
    private string _matchID = "";

    public Action<string, bool> OnStatusUpdate;
    public Action<bool> OnUpdateButton;

    private static PlayfabRestApiService _instance = null;

    private void Awake()
    {
        _playfabHttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(TypeHeaderValue));

        if (_instance is null)
        {
            DontDestroyOnLoad(this);
            _instance = this;
        }
        else
        {
            Destroy(this);
        }
    }

    public async void LoginPlayer()
    {
        var requestJson = new JSON();
        requestJson.Add("CustomID", PlayerCustomID);
        requestJson.Add("CreateAccount", false);
        requestJson.Add("TitleID", TitleID);
        _requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);

        _request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Client/LoginWithCustomID", _requestData);
        _response = await _request.Content.ReadAsStringAsync();

        if (_request.IsSuccessStatusCode)
        {
            var responseJson = JSON.ParseString(_response);

            string token = responseJson.GetJSON("data").GetJSON("EntityToken").GetString("EntityToken");
            _playfabHttpClient.DefaultRequestHeaders.Add("X-EntityToken", token);

            string sessionTicket = responseJson.GetJSON("data").GetString("SessionTicket");
            _playfabHttpClient.DefaultRequestHeaders.Add("X-Authorization", sessionTicket);

            OnUpdateButton?.Invoke(true);
            OnStatusUpdate?.Invoke("로그인 성공", false);
        }
        else
        {
            OnStatusUpdate?.Invoke($"플레이어를 로그인할 수 없습니다. 오류 {(int)_request.StatusCode} 메시지: \n{_response}", true);
        }
    }

    public async void CreateTicket()
    {
        var requestJson = new JSON();

        var entityProperty = new JSON();
        entityProperty.Add("Id", PlayerTitleID);
        entityProperty.Add("Type", "title_player_account");

        var creatorProperty = new JSON();
        creatorProperty.Add("Entity", entityProperty);

        requestJson.Add("Creator", creatorProperty);
        requestJson.Add("GiveUpAfterSeconds", 90);
        requestJson.Add("QueueName", QueueName);
        _requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);

        _request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Match/CreateMatchmakingTicket", _requestData);
        _response = await _request.Content.ReadAsStringAsync();

        if (_request.IsSuccessStatusCode)
        {
            var responseJson = JSON.ParseString(_response);
            _ticketID = responseJson.GetJSON("data").GetString("TicketId");

            OnStatusUpdate?.Invoke($"티켓이 생성되었습니다, ID #{_ticketID}", false);

            GetMatchIdAttempt();
        }
        else
        {
            OnUpdateButton?.Invoke(true);
            OnStatusUpdate?.Invoke($"티켓을 생성할 수 없습니다. 오류 {(int)_request.StatusCode} 메시지: \n{_response}", true);
        }
    }

    private async void GetMatchIdAttempt()
    {
        bool isMatchFound = false;
        var requestJson = new JSON();
        requestJson.Add("EscapeObject", true);
        requestJson.Add("QueueName", QueueName);
        requestJson.Add("TicketId", _ticketID);
        _requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);

        while (!isMatchFound)
        {
            _request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Match/GetMatchmakingTicket", _requestData);
            _response = await _request.Content.ReadAsStringAsync();

            if (_request.IsSuccessStatusCode)
            {
                var responseJson = JSON.ParseString(_response);
                string ticketStatus = responseJson.GetJSON("data").GetString("Status");
                isMatchFound = ticketStatus == "Matched";

                if (isMatchFound)
                {
                    _matchID = responseJson.GetJSON("data").GetString("MatchId");
                    OnStatusUpdate?.Invoke($"티켓이 매칭되었습니다, ID #{_matchID}", false);

                    StartConnectionAttempt();
                }
                else if (ticketStatus == "Canceled")
                {
                    isMatchFound = true;
                    string reason = responseJson.GetJSON("data").GetString("CancellationReason");

                    OnUpdateButton?.Invoke(true);
                    OnStatusUpdate?.Invoke($"{reason} 때문에 티켓이 취소되었습니다", false);
                }
                else
                {
                    OnStatusUpdate?.Invoke("매치를 찾지 못했습니다. 10초 후 다시 시도합니다...", false);
                    await Task.Delay(10000);
                }

            }
            else
            {
                isMatchFound = true;
                OnUpdateButton?.Invoke(true);
                OnStatusUpdate?.Invoke($"티켓 정보를 가져올 수 없습니다. 오류 {(int)_request.StatusCode} 메시지: \n{_response}", true);
            }
        }
    }

    private async void StartConnectionAttempt()
    {
        bool gotConnectionInfo = false;

        var requestJson = new JSON();
        requestJson.Add("Keys", new List<string> { _matchID });

        _requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);

        while (!gotConnectionInfo)
        {
            _request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Client/GetUserData", _requestData);
            _response = await _request.Content.ReadAsStringAsync();

            if (_request.IsSuccessStatusCode)
            {
                var responseJson = JSON.ParseString(_response);
                var dataObject = responseJson.GetJSON("data").GetJSON("Data");

                if (dataObject.Count > 0)
                {
                    gotConnectionInfo = true;
                    string connectionInfo = dataObject.GetJSON(_matchID).GetString("Value");
                    string serverAddress = connectionInfo.Split(':')[0];

                    if (ushort.TryParse(connectionInfo.Split(':')[1], out ushort serverPort))
                    {
                        InstanceFinder.TransportManager.Transport.SetPort(serverPort);
                        InstanceFinder.TransportManager.Transport.SetClientAddress(serverAddress);
                        Debug.Log($"포트 {serverPort} 및 IP {serverAddress}를 사용하여 서버에 연결 중");
                        InstanceFinder.ClientManager.StartConnection();
                    }
                    else
                    {
                        OnUpdateButton?.Invoke(true);
                        OnStatusUpdate?.Invoke($"서버 포트 값을 파싱하는 중 오류", true);
                    }
                }
                else
                {
                    OnStatusUpdate?.Invoke("연결 데이터가 없습니다. 10초 후 다시 시도합니다...", false);
                    await Task.Delay(10000);
                }
            }
            else
            {
                gotConnectionInfo = true;
                OnUpdateButton?.Invoke(true);
                OnStatusUpdate?.Invoke($"플레이어 데이터를 가져올 수 없습니다. 오류 {(int)_request.StatusCode} 메시지: \n{_response}", true);
            }
        }
    }

    public async void RemovePlayerData()
    {
        var requestJson = new JSON();
        requestJson.Add("KeysToRemove", new string[] { _matchID });

        _requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);
        _request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Client/UpdateUserData", _requestData);
        _response = await _request.Content.ReadAsStringAsync();

        if (!_request.IsSuccessStatusCode)
        {
            Debug.Log($"플레이어 데이터를 업데이트할 수 없습니다. 오류 {(int)_request.StatusCode} 메시지: \n{_response}");
        }
    }

    ...
}
```

#### PlayerDataUpdateService

```cs
public class PlayerDataUpdateService : NetworkBehaviour
{
    private PlayfabRestApiService _playfabService;

    ...

    public override void OnStartClient()
    {
        _playfabService = FindObjectOfType<PlayfabRestApiService>();
        _playfabService.RemovePlayerData();
    }
}
```

### 게임 서버 빌드 및 컨테이너화

서버 빌드를 컨테이너화하기 전에, 다음을 활성화했는지 확인하세요 `Start On Headless` 옵션을 `을(를) 선택하고` 게임 오브젝트에서.

다음으로 Edgegap 플러그인을 엽니다 `Tools/Edgegap Hosting` 툴바 메뉴. 다음을 확인하세요 `Edgegap API Token` 그리고 게임을 위한 애플리케이션을 생성하거나 로드하세요. 다음이 일치하는지 확인하세요 `포트` 값이 다음의 Tugboat 컴포넌트와 일치하는지 `을(를) 선택하고` 게임 오브젝트. 다음을 선택하세요 `UDP` 프로토콜, 그런 다음 `New Version Tag`을 입력합니다. 앱 이름과 버전이 Cloud Script를 설정할 때 사용한 값과 모두 일치하는지 확인하세요.

모든 설정이 올바르게 완료되면, 다음을 클릭하세요 `Build and Push`를 클릭하면, 짧은 대기 시간 후 게임 서버가 자동으로 컨테이너화되고 Edgegap 대시보드에 새로운 애플리케이션 버전이 생성됩니다.

{% hint style="info" %}
앱 버전을 생성할 때, 플러그인은 기본적으로 포트 이름을 \`"Game Port"\`로 지정합니다.

플러그인을 사용하지 않고 수동으로 컨테이너화 및 앱 버전을 생성하는 경우, 사용할 포트를 선택할 때 해당 이름을 사용해야 합니다.
{% endhint %}

### 테스트

먼저 다음을 비활성화했는지 확인하세요 `Start On Headless` 옵션을 `을(를) 선택하고` 게임 오브젝트. 다음을 설정하세요 `PlayfabRestApiService` 스크립트를 다음과 함께 `Custom ID` 및 `첫 번째 플레이어의 Title ID` 와 게임의 `첫 번째 플레이어의 Title ID` 및 `Queue Name`과 함께. 다음에서 `Build Settings`에서, 이러한 설정으로 새로운 클라이언트 빌드를 생성합니다.

빌드가 완료되면, 다음을 변경하세요 `Custom ID` 및 `첫 번째 플레이어의 Title ID` 값을 두 번째 플레이어의 값으로 변경하세요. 원하는 경우 별도의 클라이언트 빌드를 생성할 수 있지만, 플레이 모드의 Unity 에디터도 동일하게 잘 작동합니다.

두 클라이언트가 실행 시 정상적으로 로그인하면, 두 인스턴스의 `Start Game` 버튼을 클릭하세요. 잠시 후 PlayFab의 매치메이커를 통해 매치된 다음, 서버 설정이 완료되면 Edgegap의 동일한 배포에 연결됩니다.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.edgegap.com/docs.edgegap.com-ko/docs/tools-and-integrations/playfab-bridge.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
