# Playfab 桥接

本指南将向你展示如何使用 PlayFab 设置游戏，以便在玩家通过 PlayFab 匹配器完成匹配后，自动在 Edgegap 上部署服务器。客户端将通过 PlayFab 玩家数据（Player Data）访问连接到服务器所需的信息，服务器在准备就绪后会更新这些数据。在开始之前，我们预期你：

* 你目前在使用 Playfab；
* 你已经 [创建了](https://docs.edgegap.com/zh/learn/bian-pai/application-and-versions) 与 [部署了](https://docs.edgegap.com/zh/learn) 一个 Edgegap 应用。

你可以在我们的 [GitHub](https://github.com/edgegap/sample-playfab-matchmaker-fishnet)上找到最终示例项目。该示例使用 Unity v2021.3.10 和 Fishnet v3.11.14 作为网络代码进行测试。

### 设置 PlayFab Cloud Script

#### 创建新函数

登录你的 PlayFab 账户，然后在控制台导航到你的游戏总览页面。在 `Automation（自动化）` 选项卡中，创建一个新的 Cloud Script 函数，用于向 Edgegap API 发送请求以部署新服务器。

你需要在请求中传递一个玩家纬度/经度位置列表，这样服务器就能在对所有人来说尽可能最佳的位置进行部署。

你还需要以键值对的形式传递一些自定义环境变量，即：

* 由 `Match ID（匹配 ID）` 由 PlayFab 匹配器生成；
* 一个 PlayFab `Title Secret Key（标题密钥）`，可在你的标题设置中找到；
* 一个 `列表` 包含每位玩家的 `Master Player Account Id（主玩家账户 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 标题密钥` 对玩家隐藏。
{% endhint %}

### 自动化规则

仍在 `Automation（自动化）`下，导航到 `Rules（规则）` 选项卡。按以下设置创建一个新规则：

* 将 `Event Type（事件类型）` 转向 `playfab.matchmaking.match_found`;
* 添加一个新的 `Action（操作）`:
  * 将 `Type（类型）` 转向 `Execute Entity Cloud Script（执行实体 Cloud Script）`;
  * 将 `Cloud Script Function（函数）` 转向 `StartEdgegapDeployment`.

<figure><img src="https://3334189208-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FsR0dHSFv9ymoC0DO5G8J%2Fuploads%2Fgit-blob-558d1a8ab45a1545c4c7d64d1963a0f1e682645c%2Fautomation-rule.png?alt=media" alt=""><figcaption></figcaption></figure>

### 匹配器队列与玩家

在 `Multiplayer（多人）` 选项卡下的 `Matchmaking（匹配）`中，确保有可用的队列，否则创建一个新队列。为了快速测试，将其设置为简单地匹配任意两个玩家。

你需要在你的 Playfab 标题下注册足够的玩家以满足匹配器规则，因此此处只需两名玩家即可。你可以在 `Players（玩家）` 选项卡下创建新的玩家（如有需要）。在此示例项目中，请务必记录这些玩家的 `Custom ID（自定义 ID）` 与 `Title Player Account ID（标题玩家账户 ID）` 以便稍后测试。

### 编辑游戏

#### 服务器

在 Unity 编辑器中打开游戏示例后，我们在 `BattleScene` 中（位于 `Assets/SpaceEdge/Scenes`下）添加了一个新的空 gameObject。然后我们将一个名为 `PlayerDataUpdateService` 的新 C# 脚本挂载到它上面。它包含一个在服务器准备就绪后执行的回调函数。

我们还创建了第二个脚本，名为 `PlayfabRestApiService`，其中包含需要发送到 PlayFab 不同 API 的各种请求。

在 `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` 作为认证，我们向 PlayFab Server API 发送请求以更新 `Player Data（玩家数据）` 中每个对应 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` 脚本挂载到了该场景中的一个新 gameObject 上。

玩家需要做的第一件事是使用 PlayFab 登录，这将允许他们向 PlayFab 的 Client 和 Multiplayer API 发送额外的请求。

{% hint style="info" %}
我们为简化使用 `LoginWithCustomID` 端点，不过根据实际情况（例如移动游戏）有更合适的登录实现方式。
{% endhint %}

当玩家登录并且 `X-Authorization` 与 `X-EntityToken` 这两个请求头为后续请求正确设置后，他们可以使用 `Start Game（开始游戏）` UI 按钮创建一个新的匹配票据（ticket）。这将向 PlayFab 的多人 API 发送请求并存储 `ticket ID（票据 ID）` 以供后续使用。

之后，游戏客户端会每隔几秒定期检查该票据的状态，直到找到匹配或票据被取消。一旦找到匹配， `match ID（匹配 ID）` 会被存为一个变量。

随后，客户端会定期向 PlayFab 的 Client API 发送请求以获取玩家的标题 `Player Data（玩家数据）`，使用 `match ID（匹配 ID）` 作为需要检索的特定数据的键。一旦找到数据，它会在网络代码的 `地址` 和外部 `端口` 值中设置 `传输（transport）` 组件，然后连接到服务器。

当客户端正确连接并启动后，会发送请求从该玩家的 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("Login Successful", false);
        }
        else
        {
            OnStatusUpdate?.Invoke($"Unable To Login Player, Error {(int)_request.StatusCode} With Message: \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($"Ticket Created, ID #{_ticketID}", false);

            GetMatchIdAttempt();
        }
        else
        {
            OnUpdateButton?.Invoke(true);
            OnStatusUpdate?.Invoke($"Unable To Create Ticket, Error {(int)_request.StatusCode} With Message: \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($"Ticket Has Been Matched, ID #{_matchID}", false);

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

                    OnUpdateButton?.Invoke(true);
                    OnStatusUpdate?.Invoke($"Ticket Has Been Cancelled Due To {reason}", false);
                }
                else
                {
                    OnStatusUpdate?.Invoke("Match Not Found, Retrying In 10 Seconds...", false);
                    await Task.Delay(10000);
                }

            }
            else
            {
                isMatchFound = true;
                OnUpdateButton?.Invoke(true);
                OnStatusUpdate?.Invoke($"Could Not Get Ticket Info, Error {(int)_request.StatusCode} With Message: \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($"Connecting To Server Using Port {serverPort} And IP {serverAddress}");
                        InstanceFinder.ClientManager.StartConnection();
                    }
                    else
                    {
                        OnUpdateButton?.Invoke(true);
                        OnStatusUpdate?.Invoke($"Error While Parsing Server Port Value", true);
                    }
                }
                else
                {
                    OnStatusUpdate?.Invoke("No Connection Data Found, Retrying In 10 Seconds...", false);
                    await Task.Delay(10000);
                }
            }
            else
            {
                gotConnectionInfo = true;
                OnUpdateButton?.Invoke(true);
                OnStatusUpdate?.Invoke($"Could Not Get Player Data, Error {(int)_request.StatusCode} With Message: \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($"Could Not Update PlayerData, Error {(int)_request.StatusCode} With Message: \n{_response}");
        }
    }

    ...
}
```

#### PlayerDataUpdateService

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

    ...

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

### 构建游戏服务器与容器化

在对服务器构建进行容器化之前，确保启用 `Start On Headless（无头启动）` 选项于该 `NetworkManager` gameObject。

通过 `Tools/Edgegap Hosting` 工具栏菜单打开 Edgegap 插件。验证你的 `Edgegap API Token（Edgegap API 令牌）` ，并为游戏创建或加载一个应用。确保 `端口` 值与该 `NetworkManager` gameObject 的 Tugboat 组件中的值匹配。选择 `UDP` 协议，然后输入一个 `New Version Tag（新版本标签）`。请确保应用名称和版本与设置 Cloud Script 时使用的值一致。

完成正确设置后，点击 `Build and Push（构建并推送）`，这将自动容器化你的游戏服务器，并在短暂等待后在 Edgegap 控制台上创建一个新的应用版本。

{% hint style="info" %}
创建应用版本时，插件将默认把端口命名为 \`"Game Port"\`。

如果你选择手动容器化并创建应用版本，而不是使用插件，请确保在选择要使用的端口时使用该名称。
{% endhint %}

### 测试

首先确保禁用 `Start On Headless（无头启动）` 选项于该 `NetworkManager` gameObject。将 `PlayfabRestApiService` 脚本中的 `Custom ID（自定义 ID）` 与 `Title ID（标题 ID）` 设置为你的第一个玩家的值，并设置你的游戏的 `Title ID（标题 ID）` 与 `Queue Name（队列名称）`。在 `Build Settings（构建设置）`中，使用这些设置创建一个新的客户端构建。

构建完成后，将 `Custom ID（自定义 ID）` 与 `Title ID（标题 ID）` 值更改为你的第二个玩家的值。你也可以创建一个单独的客户端构建，尽管在 Unity 编辑器的运行模式下也同样可行。

如果两个客户端在启动时都正确登录，点击两个实例的 `Start Game（开始游戏）` 按钮。稍候片刻，它们将通过 PlayFab 的匹配器匹配，然后在服务器完成设置后连接到 Edgegap 上的同一部署。
