> For the complete documentation index, see [llms.txt](https://docs.edgegap.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.edgegap.com/zh/docs/tools-and-integrations/playfab-bridge.md).

# 从 PlayFab 部署

保留你的 PlayFab 匹配器，并将游戏服务器部署到 Edgegap，结合两个平台的优势。

了解如何在 Edgegap 上轻松部署专用服务器，并 **继续使用 PlayFab 匹配器**.

## 🚀 入门

在开始之前，请确保你已经：

* 熟悉了 [PlayFab 控制台](https://developer.playfab.com/en-us/r/sign-in)，尤其是 [玩家数据](https://learn.microsoft.com/en-us/xbox/playfab/player-progression/player-data/) 和 [匹配](https://learn.microsoft.com/en-us/xbox/playfab/multiplayer/matchmaking/).
* 已完成项目的入门步骤 - [Unity](/zh/unity.md) / [Unreal Engine](/zh/unreal-engine.md) / [Godot](/zh/godot.md).
* 已使用插件或 [控制台](https://app.edgegap.com/deployment-management/deployments/create)，并通过 IP 和端口进行连接。

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

### PlayFab 云脚本

#### 创建新函数

登录你的 PlayFab 账户，然后在控制台中进入你游戏的概览页面。在 `自动化` 选项卡中，创建一个新的云脚本函数，用于向 Edgegap API 发送请求以部署新服务器。

你需要在请求中传递玩家纬度/经度位置列表，以便服务器能为所有人部署在最佳位置。

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

* 该 `匹配 ID` 由 PlayFab 匹配器生成；
* 一个 PlayFab `标题密钥`，可在你的标题设置中找到；
* 一个 `列表` 每个玩家的 `主玩家账户 ID`.

这些变量将用于更新玩家的 PlayFab `玩家数据` ，以提供连接到服务器所需的信息。请记住你用于设置这些变量的键；在本示例中，它们是 `"PLAYFAB_MATCH_ID"`, `"PLAYFAB_TITLE_SECRET"`，以及 `"PLAYFAB_MATCH_PLAYERS_ID_LIST"`.

{% hint style="info" %}
创建此函数最简单的方法是在 `修订版（旧版）` 选项卡中部署一个新修订版本，并添加以下代码。请务必先将 `Edgegap_AppName`, `Edgegap_AppVersion`, `Edgegap_ApiToken` 和 `PlayFab_TitleSecret` 顶部变量替换为你自己的值。

应用名称和版本稍后会在 Edgegap 上容器化游戏服务器时使用。

玩家 ID 列表在这里作为一个字符串存储，每个 ID 之间用逗号分隔。
{% endhint %}

{% code expandable="true" %}

```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 GeoUserList = [];
  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,
      },
    });

    GeoUserList .push({
      user_type: "geo_coordinates",
      user_data: {
        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/v2/deployments",
      "post",
      JSON.stringify({
        application: Edgegap_AppName,
        version: Edgegap_AppVersion,
        users: [...GeoUserList],
        tags: [MatchId.slice(0, 20)],
        environment_variables: [
          {
            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);
};
```

{% endcode %}

{% hint style="info" %}
此云脚本函数在游戏客户端和 Edgegap API 之间创建一个接口，从而使你的 `Edgegap API 令牌` 和 `PlayFab 标题密钥` 对玩家隐藏。
{% endhint %}

### 自动化规则

仍在 `自动化`，请进入 `规则` 选项卡。使用以下设置创建一条新规则：

* 将 `事件类型` 设置为 `playfab.matchmaking.match_found`;
* 添加一个新的 `操作`:
  * 将 `类型` 设置为 `执行实体云脚本`;
  * 将 `云脚本函数` 设置为 `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>

### 匹配队列和玩家

在 `多人游戏` 选项卡中，在 `匹配`，请确保有一个可用队列，否则创建一个新的。为快速测试，可将其设置为仅匹配任意两名玩家。

你需要在你的 PlayFab 标题下注册足够的玩家以满足匹配器规则，因此在这种情况下只需两个即可。你可以在 `玩家` 选项卡下创建新玩家（如有需要）。对于此示例项目，请务必记录这些玩家的 `自定义 ID` 和 `标题玩家账户 ID` 以便稍后测试。

### 编辑游戏

#### 服务器

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

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

在 `PlayerDataUpdateService.cs`中，我们获取了部署时注入的几个环境变量：

* 自定义变量：
  * `PLAYFAB_MATCH_ID`;
  * `PLAYFAB_TITLE_SECRET`;
  * `PLAYFAB_MATCH_PLAYERS_ID_LIST`
* 默认 [Edgegap 变量](/zh/learn/bian-pai/deployments.md#injected-environment-variables):
  * `ARBITRIUM_PUBLIC_IP`;
  * `ARBITRIUM_PORT_GAMEPORT_EXTERNAL`;

使用 `PLAYFAB_TITLE_SECRET` 作为身份验证，我们向 PlayFab Server API 发送请求以更新 `玩家数据` 中每个对应 ID 的 `PLAYFAB_MATCH_PLAYERS_ID_LIST` ，并使用以下键值对：

* 键： `PLAYFAB_MATCH_ID`;
* 值： `"{ARBITRIUM_PUBLIC_IP}:{ARBITRIUM_PORT_GAMEPORT_EXTERNAL}"`

#### PlayerDataUpdateService

{% code expandable="true" %}

```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";

    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 port = Environment.GetEnvironmentVariable("ARBITRIUM_PORT_GAMEPORT_EXTERNAL");

        if (!string.IsNullOrEmpty(portsMapping))
        {
            foreach (string playerId in playerIdList)
            {
                if (!string.IsNullOrEmpty(playerId))
                {
                    _playfabService.UpdatePlayerData(titleSecretKey, playerId, matchID, $"{address}:{port}");
                }
            }
        }
        else
        {
            Debug.Log("端口映射不可用");
        }
    }

    ...
}
```

{% endcode %}

#### PlayfabRestApiService

{% code expandable="true" %}

```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($"无法更新 ID 为 {playerID} 的 PlayerData，错误 {(int)_request.StatusCode}，消息：\n{_response}");
        }
    }
}
```

{% endcode %}

#### 客户端

客户端设置将由 `MainMenu` 场景（位于 `Assets/SpaceEdge/Scenes`）。我们创建了一个名为 `MainMenuService` 来管理该场景的 UI 并向玩家显示各种消息，同时还向 `PlayfabRestApiService.cs`添加了新函数。我们还将 `PlayfabRestApiService` 脚本附加到此场景中的一个新 gameObject 上。

玩家首先需要使用 PlayFab 登录，这将使他们能够向 PlayFab 的客户端和多人游戏 API 发送更多请求。

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

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

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

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

当客户端成功连接并正常启动后，会发送请求，从该玩家的 PlayFab 中移除连接信息 `玩家数据` ，这得益于一个回调函数。

{% hint style="info" %}
务必确保这些键值对被删除，最好在部署终止之前完成。这是为了避免让 `玩家数据` 中充斥过多不必要的条目。
{% endhint %}

#### MainMenuService

{% code expandable="true" %}

```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);
}
```

{% endcode %}

#### PlayfabRestApiService

{% code expandable="true" %}

```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}");
        }
    }

    ...
}
```

{% endcode %}

#### PlayerDataUpdateService

{% code expandable="true" %}

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

    ...

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

{% endcode %}

### 构建游戏服务器并进行容器化

现在使用新增功能重复构建步骤 - [Unity](/zh/unity.md) / [Unreal Engine](/zh/unreal-engine.md) / [Godot](/zh/godot.md).

### 测试

如果使用 FishNet - 请先禁用 `无头模式启动` 选项，在 `NetworkManager`.

将 `PlayfabRestApiService` 脚本以及 `自定义 ID` 和 `Title ID` 你第一个玩家的，以及你游戏的 `Title ID` 和 `队列名称`。在 `构建设置`，使用这些设置创建一个新的客户端构建。

构建完成后，将 `自定义 ID` 和 `Title ID` 值改为你第二个玩家的值。你也可以单独创建一个客户端构建，不过 Unity 编辑器的播放模式应该也同样可以工作。

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