# Mirror

### Mirror 在 Edgegap 上

本指南将帮助您在 Edgegap 上为使用以下内容的 Unity 项目创建无头服务器： [Mirror](https://mirror-networking.com/) 作为其网络解决方案。

本指南将使用开源示例项目 `Tanks`，该示例已包含在您的 Mirror 示例中，位置为 Assets/Mirror/Examples/Tanks。

最终示例可以在我们的 [GitHub](https://github.com/edgegap/netcode-sample-unity-mirror).

### 构建游戏服务器

准备好游戏后，前往 Unity 编辑器的 `构建` 界面，位于 `文件 -> 构建设置` 在顶部菜单中。请根据您使用的 Unity 版本选择正确的预设。

* 在 2021.2 之前的版本：
  * 将 `目标平台` 设置为 `Linux`;
  * 将 `架构` 设置为 `x86_64`;
  * 勾选 `服务器构建` 选项。
* 否则：
  * 将 `平台` 设置为 `专用服务器`;
  * 将 `目标平台` 设置为 `Linux`.

然后按构建并选择一个名为 `linux_server` 的新空文件夹作为文件目标。将 `linux_server` 文件夹转移到第二个空文件夹中，本文档将其称为 `[SERVER BUILD]` 文件夹。

### 为专用游戏服务器容器化

我们将在此部分创建包含专用游戏服务器的 Docker 镜像。您可能还想阅读 [Docker 中的 Unity 服务器](https://docs.edgegap.com/zh/docs/sample-projects/unity-netcodes/broken-reference).

如果您需要有关在 Edgegap 上使用 Docker 的更多信息，请参阅 [此](https://docs.edgegap.com/zh/docs/tools-and-integrations/container/docker) 文档。

#### Dockerfile

```
FROM ubuntu:bionic
维护者 <author_detail>

ARG debian_frontend=noninteractive
ARG docker_version=17.06.0-ce

RUN apt-get update && \
    apt-get install -y libglu1 xvfb libxcursor1 ca-certificates && \
    apt-get clean && \
    update-ca-certificates

EXPOSE 3389/TCP
EXPOSE [GAME PORT]/TCP
EXPOSE [GAME PORT]/UDP

COPY linux_server/ /root/linux_server/
COPY boot.sh /boot.sh

WORKDIR /root/
ENTRYPOINT ["/bin/bash", "/boot.sh"]
```

记录您用于网络通信的端口，即本文中所称的 `[GAME PORT]`。默认使用的端口是 `7777`。您可以在 Unity 编辑器中找到此信息，位于 `NetworkManager` 游戏对象的 `传输` 组件中。

* 将上面的行复制并粘贴到位于 `[SERVER BUILD]`的 Dockerfile 中。修改 `[GAME PORT]` 占位符以匹配您的游戏端口。

将 `[GAME PORT]` 在 TCP 和 UDP 上打开可让您在 `NetworkManager` Mirror 组件中使用任意您偏好的传输方式。最后，在 `根目录下创建一个名为` boot.sh `[SERVER BUILD]` 的文件。它将在容器中启动镜像时执行。

* 复制以下两行，确保将 `[YOUR GAME]` 占位符替换为生成文件的名称。

```
xvfb-run --auto-servernum --server-args='-screen 0 640X480X24:32' /root/build/[YOUR GAME].x86_64 -batchmode -nographics
```

此时，您应具有以下目录结构：

> * `[SERVER BUILD] 文件夹` > > - `Dockerfile` > > - `根目录下创建一个名为` > > - `linux_server` 文件夹 > > > - Unity 生成的文件

* 在 `[SERVER BUILD]` 文件夹中启动命令提示符，然后运行以下 Docker 命令：

{% hint style="warning" %}
对于 ARM CPU（Mac M1、M2 等）用户，向构建命令添加 `--platform linux/amd64`  选项。
{% endhint %}

#### 使用 Linux

```bash
# 构建镜像
docker build . -t <IMAGE_NAME>:<IMAGE_VERSION>

# 登录，系统会提示输入密码
docker login -u '<REGISTRY_USERNAME>' <REGISTRY_URL>

# 为镜像添加与仓库对应的另一个标签
docker image tag <IMAGE_NAME>:<IMAGE_VERSION> <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>

# 推送镜像
docker push <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>
```

#### 使用 cmd

```bash
# 构建镜像
docker build . -t <IMAGE_NAME>:<IMAGE_VERSION>

# 登录，系统会提示输入密码
docker login -u <REGISTRY_USERNAME> <REGISTRY_URL>

# 为镜像添加与仓库对应的另一个标签
docker image tag <IMAGE_NAME>:<IMAGE_VERSION> <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>

# 推送镜像
docker push <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>
```

#### 使用 Powershell

```bash
# 构建镜像
docker build . -t <IMAGE_NAME>:<IMAGE_VERSION>

# 登录，系统会提示输入密码
docker login -u '<REGISTRY_USERNAME>' <REGISTRY_URL>

# 为镜像添加与仓库对应的另一个标签
docker image tag <IMAGE_NAME>:<IMAGE_VERSION> <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>

# 推送镜像
docker push <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>
```

执行这些命令后，如果您使用的是 Edgegap 容器注册表，您应该能够在 Edgegap 网站上看到已上传的镜像。参见 [此文档](https://docs.edgegap.com/zh/learn/advanced-features/edgegap-container-registry) 如果您想使用 Edgegap 注册表。您也可以使用其他私人注册表。

### 部署到 Edgegap

导航到网站的 `应用与游戏` 页面。点击右上角的 `创建新建` 按钮以访问应用表单。以下是各字段及其正确填写方法：

* 应用名称：可以是任何便于您在多个应用中识别该应用的显著名称。
* 镜像：可以是任何便于您识别该应用的特定镜像名称。
* 版本名称：您可能希望使用版本名称来描述正在部署版本的范围。例如可以使用 “demo”、“production”、“v1”、“v2”。
* 容器：
  * 仓库："\[URL]"，其中 \[URL] 是您在容器仓库页面中显示的凭据中的值。
  * 镜像仓库："\[PROJECT]/\[YOUR GAME]"，其中 \[PROJECT] 和 \[YOUR GAME] 是您在之前推送 Docker 镜像时使用的值。
  * 标签："\[TAG]"，其中 \[TAG] 是您在之前推送 Docker 镜像时使用的值。
  * 勾选“使用私人仓库”
  * 私人注册表用户名："\[USERNAME]"，其中 \[USERNAME] 是您凭据中的值。
  * 私人注册表令牌："\[TOKEN]"，其中 \[TOKEN] 是您凭据中的值。
  * 需求：保持原样。
  * 端口：
    * 点击 `+ 添加端口` 链接以添加新端口，并添加以下条目：
      * `[GAME PORT]` - `TCP/UDP` - 禁用验证
      * 3389 - TCP - 禁用验证

应用创建完成后，您可以按 `部署` 按钮以继续部署您的游戏服务器。选择要部署的区域，并根据游戏输入要生成的随机玩家数量。通过验证以下内容来检查一切运行是否顺利：

* 最新状态应显示为 `就绪`.
* 在 `端口映射` 选项卡中，您应能看到在应用创建表单中设置的端口：

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

### 在客户端应用中添加示例 HUD

* 将 `端口` 值设置在 `传输` 组件的 `NetworkManager` 到部署的 `端口映射` 选项卡中定义的外部端口。

在此示例中，端口设置为 `31887`。这主要取决于您开发的游戏，并且很可能会在游戏代码中以编程方式设置。

* 将 `网络地址` 的值设置为 `网络管理器` 为您部署的 `主机`。该 URL 可在仪表板的 `部署摘要` 中或通过 API 找到。

在此示例中，地址设置为 `0ace560706a5.pr.edgegap.net`。同样，此值很可能会在客户端与负责匹配的主服务器/API 通信时以编程方式设置。

使用正确的信息，您应该能够正常连接到游戏服务器并立即开始游戏。

现在您有一个可按需部署的 Mirror 项目！

使用基于座位的部署时，可以使用 Mirror 创建一个系统，该系统在玩家从服务器断开连接后自动移除悬挂的 Edgegap 会话，使用 `NetworkManager` 回调函数，以及一个 `NetworkBehaviour` 脚本附加到使用远程过程调用（RPC）函数和命令（Command）函数的玩家预制体上。

当服务器启动时， `NetworkManager` 会从 Edgegap API 检索与其部署关联的 `会话 ID 列表` 并将其存储。随后，当新玩家连接到服务器时，会通过 `RPC` 启动一个客户端函数，该函数会将 `玩家的 IP 地址` 通过一个 `命令`发送回服务器。服务器使用玩家的 IP 在每个会话的数据中检查是否有匹配；服务器使用其缓存的 ID 通过 Edgegap API 获取会话数据。如果找到匹配， `会话 ID` 将映射到该玩家的 `NetworkConnectionToClient`.

由于服务器启动后可以添加新会话，如果第一次无法找到匹配，会话 ID 列表会更新并检查新会话。

最后，一旦玩家从服务器断开连接，服务器使用该玩家的 `NetworkConnectionToClient` 来检索其关联的 `会话 ID`，然后使用 Edgegap API 删除该会话。这将释放部署中的一个套接字以供新玩家加入。

#### PlayerNetworkBehaviour

```cs
public class PlayerNetworkBehaviour : NetworkBehaviour
{
    [ClientRpc]
    public void RpcSendClientIpAddress()
    {
        StartCoroutine(FetchPublicIpAndSendToServer());
    }

    private IEnumerator FetchPublicIpAndSendToServer(" alt=""><figcaption></figcaption></figure>
    {
        UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");
        yield return request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.Success)
        {
            string responseText = request.downloadHandler.text;
            string clientIp = JObject.Parse(responseText)["ip"].ToString();
            CmdSendIpAddressToServer(clientIp);
        }
        else
        {
            Debug.LogError("获取公网 IP 地址失败。");
        }
    }

    [Command]
    public void CmdSendIpAddressToServer(string clientIp)
    {
        NetworkConnectionToClient conn = connectionToClient;
        if (conn != null)
        {
            CustomNetworkManager.Instance.StoreClientIpAddress(conn, clientIp);
        }
        else
        {
            Debug.LogError("未找到连接。");
        }
    }
}
```

#### CustomNetworkManager

```cs
public class CustomNetworkManager : NetworkManager
{
    private static readonly string edgegapUrl = "https://api.edgegap.com/v1";
    private string apiToken;
    private string deploymentId;
    private Dictionary<NetworkConnectionToClient, string> connectionToSessionIdMap = new Dictionary<NetworkConnectionToClient, string>();
    private Dictionary<NetworkConnectionToClient, bool> mappingInProgress = new Dictionary<NetworkConnectionToClient, bool>();

    private ArrayList currentDeploymentSessions = new();
    private ArrayList newSessions = new();

    public static CustomNetworkManager Instance;

    public override void Awake()
    {
        base.Awake();
        Instance = this;
    }

    public override void OnStartServer()
    {
        base.OnStartServer();
        deploymentId = System.Environment.GetEnvironmentVariable("ARBITRIUM_REQUEST_ID");
        apiToken = System.Environment.GetEnvironmentVariable("API_TOKEN");
        if (string.IsNullOrEmpty(apiToken))
        {
            Debug.LogError("在环境变量中未找到 Edgegap API 令牌。");
        }

        StartCoroutine(GetNewSessionList());
    }

    public override void OnServerAddPlayer(NetworkConnectionToClient conn)
    {
        base.OnServerAddPlayer(conn);
        Debug.Log($"为连接添加玩家：{conn.connectionId}");

        // 从玩家对象获取 PlayerNetworkBehaviour
        PlayerNetworkBehaviour playerNetworkBehaviour = conn.identity.GetComponent<PlayerNetworkBehaviour>();
        playerNetworkBehaviour.RpcSendClientIpAddress();
    }

    public void StoreClientIpAddress(NetworkConnectionToClient conn, string clientIp)
    {
        mappingInProgress[conn] = true; // 将映射进行中标记为 true
        Debug.Log($"为连接 {conn.connectionId} 存储的 IP 地址：{clientIp}");

        // 开始将会话映射到连接
        StartCoroutine(CheckCachedSessions(conn, clientIp));
    }

    private IEnumerator GetNewSessionList()
    {
        newSessions.Clear();
        string url = $"{edgegapUrl}/status/{deploymentId}";
        UnityWebRequest request = UnityWebRequest.Get(url);
        request.SetRequestHeader("Authorization", apiToken);

        yield return request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.Success)
        {
            string responseText = request.downloadHandler.text;
            Debug.Log("部署状态响应：" + responseText);

            var json = JObject.Parse(responseText);
            var sessions = json["sessions"] as JArray;

            foreach (var session in sessions)
            {
                string sessionId = session["session_id"]?.ToString();

                if (!currentDeploymentSessions.Contains(sessionId))
                {
                    newSessions.Add(sessionId);
                    currentDeploymentSessions.Add(sessionId);
                }
            }

            Debug.Log("已初始化新会话列表");
        }
    }

    private IEnumerator CheckCachedSessions(NetworkConnectionToClient conn, string clientAddress)
    {
        foreach (string sessionId in currentDeploymentSessions)
        {
            yield return GetSessionAndMap(conn, sessionId, clientAddress);

            if (connectionToSessionIdMap.ContainsKey(conn))
            {
                break;
            }
        }

        if (!connectionToSessionIdMap.ContainsKey(conn))
        {
            Debug.Log("无法将玩家与当前缓存的会话映射");
            yield return GetNewSessionList();

            foreach (string sessionId in newSessions)
            {
                yield return GetSessionAndMap(conn, sessionId, clientAddress);

                if (connectionToSessionIdMap.ContainsKey(conn))
                {
                    break;
                }
            }
        }
    }

    private IEnumerator GetSessionAndMap(NetworkConnectionToClient conn, string sessionId, string clientAddress)
    {
        Debug.Log($"正在获取会话 ID 的会话详情：{sessionId}");
        string url = $"{edgegapUrl}/session/{sessionId}";
        UnityWebRequest request = UnityWebRequest.Get(url);
        request.SetRequestHeader("Authorization", apiToken);

        yield return request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.Success)
        {
            Debug.Log("会话获取成功。");
            string responseText = request.downloadHandler.text;
            Debug.Log("会话响应：" + responseText);

            var session = JObject.Parse(responseText);
            var sessionUsers = session["session_users"] as JArray;

            if (sessionUsers != null && sessionUsers.Count > 0)
            {
                Debug.Log($"在会话中找到 {sessionUsers.Count} 个用户。");
                foreach (var user in sessionUsers)
                {
                    string playerIp = user["ip"]?.ToString();
                    Debug.Log($"玩家 IP：{playerIp}，连接 IP：{clientAddress}");
                    if (playerIp == clientAddress)
                    {
                        connectionToSessionIdMap[conn] = sessionId;
                        Debug.Log($"将会话 ID {sessionId} 映射到连接 {conn.connectionId}（IP：{playerIp}）");

                        // 额外的确认日志
                        if (connectionToSessionIdMap.ContainsKey(conn))
                        {
                            Debug.Log($"会话 ID {sessionId} 已成功为连接 {conn.connectionId}（IP：{playerIp}）存储");
                        }
                        else
                        {
                            Debug.LogError($"未能为连接 {conn.connectionId}（IP：{playerIp}）存储会话 ID {sessionId}");
                        }

                        break;
                    }
                }
            }
            else
            {
                Debug.LogError("会话中未找到用户。");
            }
        }
        else
        {
            Debug.LogError($"获取会话时出错：{request.error}");
            Debug.LogError($"响应代码：{request.responseCode}");
            Debug.LogError($"响应文本：{request.downloadHandler.text}");
        }

        mappingInProgress[conn] = false; // 完成后将映射进行中标记为 false
    }

    public override void OnServerDisconnect(NetworkConnectionToClient conn)
    {
        base.OnServerDisconnect(conn);
        Debug.Log($"服务器断开客户端：{conn.connectionId}");

        StartCoroutine(HandleDisconnect(conn));
    }

    private IEnumerator HandleDisconnect(NetworkConnectionToClient conn)
    {
        while (mappingInProgress.ContainsKey(conn) && mappingInProgress[conn])
        {
            Debug.Log($"正在等待连接 {conn.connectionId} 的会话映射完成...");
            yield return new WaitForSeconds(0.1f);
        }

        if (connectionToSessionIdMap.TryGetValue(conn, out string sessionId))
        {
            Debug.Log($"正在删除会话 ID：{sessionId}");
            StartCoroutine(DeleteSession(sessionId));
            connectionToSessionIdMap.Remove(conn);
            mappingInProgress.Remove(conn);
            currentDeploymentSessions.Remove(sessionId);
        }
        else
        {
            Debug.LogWarning("未找到该连接的会话 ID。");
        }
    }

    private IEnumerator DeleteSession(string sessionId)
    {
        Debug.Log($"发送请求以删除会话 ID：{sessionId}");
        string url = $"{edgegapUrl}/session/{sessionId}";
        UnityWebRequest request = UnityWebRequest.Delete(url);
        request.SetRequestHeader("Authorization", apiToken);

        yield return request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.Success)
        {
            Debug.Log("会话删除成功。");
        }
        else
        {
            Debug.LogError($"删除会话时出错：{request.error}");
            Debug.LogError($"响应代码：{request.responseCode}");
            Debug.LogError($"响应文本：{request.downloadHandler.text}");
        }
    }

}
```

#### 可选功能

此脚本可以扩展为具有可选的独立功能，以帮助在特定情况下管理会话。

例如， `超时` 可以设置为在可配置时间后删除 `空座位会话` 在完整服务器初始化后 `。这是一个有用的功能，以防玩家在其匹配票据解决之前退出，一旦找到匹配将创建一个空会话。如果玩家在超时之前从未发送其 IP 地址以映射到会话 ID，则该会话将在超时后被删除以释放套接字。`另一个功能可以是在长时间不活动后

断开不活动玩家 `以释放套接字。一旦连接到服务器，客户端需要` 向服务器发送一些最小化的心跳消息 `每隔几秒` 发送到服务器， `否则如果连续错过太多`心跳消息则会被断开连接 `。心跳之间的时间间隔和连续允许丢失的最大消息数可以根据游戏需求进行配置。在此示例中，如果坦克在地图上移动或射击，将每隔几秒发送一次心跳。`PlayerNetworkBehaviour - 座位会话管理

#### private bool enableInactiveTimeout;

```cs
    public class PlayerNetworkBehaviour : NetworkBehaviour
    {
        private float clientTimeSinceLastHeartbeat = 0;
        private float secondsBetweenHeartbeats;
        secondsBetweenHeartbeats = CustomNetworkManager.Instance.GetSecondsBetweenHeartbeats();

        [ClientRpc]
        public void RpcSendClientIpAddress()
        {
            StartCoroutine(FetchPublicIpAndSendToServer());
            enableInactiveTimeout = CustomNetworkManager.Instance.IsEnableInactiveTimeout();
            clientTimeSinceLastHeartbeat = secondsBetweenHeartbeats;
            private void Update()
        }

        ...

        if (isLocalPlayer && enableInactiveTimeout)
        {
            float horizontal = Input.GetAxis("Horizontal");
            {
                float vertical = Input.GetAxis("Vertical");
                if (clientTimeSinceLastHeartbeat >= secondsBetweenHeartbeats && (Input.GetKeyDown(KeyCode.Space) || horizontal != 0 || vertical != 0))

                CmdSendHeartbeatTimeToServer(NetworkTime.predictedTime);
                {
                    clientTimeSinceLastHeartbeat = 0;
                    clientTimeSinceLastHeartbeat += Time.deltaTime;
                }

                public void CmdSendHeartbeatTimeToServer(double time)
            }
        }

        [Command]
        CustomNetworkManager.Instance.StoreClientHeartbeatTime(conn, time);
        {
            NetworkConnectionToClient conn = connectionToClient;
            if (conn != null)
            {
                CustomNetworkManager - 座位会话管理
            }
            else
            {
                Debug.LogError("未找到连接。");
            }
        }
    }
```

#### private bool listInProgress = false;

```cs
public class CustomNetworkManager : NetworkManager
{
    ...
    [Header("空会话超时")]

    [SerializeField] private bool enableEmptyTimeout;
    [SerializeField] private float deleteAfterSeconds = 15f;
    [Header("不活动玩家超时")]

    [SerializeField] private bool enableInactiveTimeout;
    [SerializeField] private int maxMissedHeartbeats = 3;
    [SerializeField] private float secondsBetweenHeartbeats = 3f;
    private Dictionary<NetworkConnectionToClient, double> connectionToLastHeartbeatMap = new();
    deploymentId = Environment.GetEnvironmentVariable("ARBITRIUM_REQUEST_ID");

    ...

    public override void OnStartServer()
    {
        base.OnStartServer();
        apiToken = Environment.GetEnvironmentVariable("API_TOKEN");
        Debug.Log("找到 Edgegap API 令牌：" + apiToken);
        if (string.IsNullOrEmpty(apiToken))
        {
            Debug.LogError("在环境变量中未找到 Edgegap API 令牌。");
        }
        else
        {
            StartCoroutine(InitiateSessionManage());
        }

        private IEnumerator InitiateSessionManage()
    }

    yield return new WaitForSeconds(0.5f);
    {
        if (enableEmptyTimeout)

        StartCoroutine(GetNewSessionList());

        StartCoroutine(StartServerInitSessionsTimeout());
        {
            listInProgress = true;
        }
    }

    ...

    private IEnumerator GetNewSessionList()
    {
        Debug.LogError($"获取会话列表时出错：{request.error}");
        newSessions.Clear();
        string url = $"{edgegapUrl}/status/{deploymentId}";
        UnityWebRequest request = UnityWebRequest.Get(url);
        request.SetRequestHeader("Authorization", apiToken);

        yield return request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.Success)
        {
            string responseText = request.downloadHandler.text;
            Debug.Log("部署状态响应：" + responseText);

            var json = JObject.Parse(responseText);
            var sessions = json["sessions"] as JArray;

            foreach (var session in sessions)
            {
                string sessionId = session["session_id"]?.ToString();

                if (!currentDeploymentSessions.Contains(sessionId))
                {
                    newSessions.Add(sessionId);
                    currentDeploymentSessions.Add(sessionId);
                }
            }

            Debug.Log("已初始化新会话列表");
        }
        else
        {
            listInProgress = false;
            Debug.LogError($"响应代码：{request.responseCode}");
            Debug.LogError($"响应文本：{request.downloadHandler.text}");
        }

        if (enableInactiveTimeout)
    }

    ...

    private IEnumerator GetSessionAndMap(NetworkConnectionToClient conn, string sessionId, string clientAddress)
    {
        Debug.Log($"正在获取会话 ID 的会话详情：{sessionId}");
        string url = $"{edgegapUrl}/session/{sessionId}";
        UnityWebRequest request = UnityWebRequest.Get(url);
        request.SetRequestHeader("Authorization", apiToken);

        yield return request.SendWebRequest();

        if (request.result == UnityWebRequest.Result.Success)
        {
            Debug.Log("会话获取成功。");
            string responseText = request.downloadHandler.text;
            Debug.Log("会话响应：" + responseText);

            var session = JObject.Parse(responseText);
            var sessionUsers = session["session_users"] as JArray;

            if (sessionUsers != null && sessionUsers.Count > 0)
            {
                Debug.Log($"在会话中找到 {sessionUsers.Count} 个用户。");
                foreach (var user in sessionUsers)
                {
                    string playerIp = user["ip"]?.ToString();
                    Debug.Log($"玩家 IP：{playerIp}，连接 IP：{clientAddress}");
                    if (playerIp == clientAddress)
                    {
                        connectionToSessionIdMap[conn] = sessionId;
                        Debug.Log($"将会话 ID {sessionId} 映射到连接 {conn.connectionId}（IP：{playerIp}）");

                        connectionToLastHeartbeatMap[conn] = conn.lastMessageTime;
                        {
                            StartCoroutine(CheckClientHeartbeat(conn));
                            #region EmptySessionTimeout
                        }

                        // 额外的确认日志
                        if (connectionToSessionIdMap.ContainsKey(conn))
                        {
                            Debug.Log($"会话 ID {sessionId} 已成功为连接 {conn.connectionId}（IP：{playerIp}）存储");
                        }
                        else
                        {
                            Debug.LogError($"未能为连接 {conn.connectionId}（IP：{playerIp}）存储会话 ID {sessionId}");
                        }

                        break;
                    }
                }
            }
            else
            {
                Debug.LogError("会话中未找到用户。");
            }
        }
        else
        {
            Debug.LogError($"获取会话时出错：{request.error}");
            Debug.LogError($"响应代码：{request.responseCode}");
            Debug.LogError($"响应文本：{request.downloadHandler.text}");
        }

        mappingInProgress[conn] = false; // 完成后将映射进行中标记为 false
    }

    ...

    private IEnumerator StartServerInitSessionsTimeout()

    while (listInProgress)
    {
        Debug.Log($"正在等待检索会话列表...");
        {
            StartCoroutine(WaitForConnectionTimeout(sessionId));
            yield return new WaitForSeconds(0.1f);
        }

        foreach (string sessionId in currentDeploymentSessions)
        {
            private IEnumerator WaitForConnectionTimeout(string sessionID)
        }
    }

    Debug.Log($"正在检查会话 {sessionID} 的空超时");
    {
        bool delete = false;
        DateTime timeout = DateTime.Now.AddSeconds(deleteAfterSeconds);
        while (!connectionToSessionIdMap.ContainsValue(sessionID))

        if (DateTime.Now >= timeout)
        {
            yield return new WaitForSeconds(0.1f);
            delete = true;
            {
                if (delete && !connectionToSessionIdMap.ContainsValue(sessionID))
                break;
            }
        }

        Debug.Log($"在 {deleteAfterSeconds}s 后未有客户端发起连接，正在删除会话 {sessionID}");
        {
            StartCoroutine(DeleteSession(sessionID));
            currentDeploymentSessions.Remove(sessionID);
            newSessions.Remove(sessionID);
            #endregion
        }
    }

    #region InactivePlayerTimeout

    private IEnumerator CheckClientHeartbeat(NetworkConnectionToClient conn)

    int counter = 0;
    {
        while (counter < maxMissedHeartbeats && connectionToSessionIdMap.ContainsKey(conn))

        double lastHeartbeatDiff = NetworkTime.localTime - connectionToLastHeartbeatMap[conn];
        {
            if (lastHeartbeatDiff >= secondsBetweenHeartbeats)

            counter += 1;
            {
                Debug.Log($"连接 {conn.connectionId} 已连续错过 {counter} 次心跳");

                counter = 0;
            }
            else
            {
                Debug.Log($"已重置连接 {conn.connectionId} 的心跳计数器");
                yield return new WaitForSeconds(secondsBetweenHeartbeats);
            }

            if (counter >= maxMissedHeartbeats && conn.isReady)
        }

        Debug.Log($"连接 {conn.connectionId} 已长时间不活动，正在断开连接");
        {
            conn.Disconnect();
            connectionToLastHeartbeatMap.Remove(conn);
            public void StoreClientHeartbeatTime(NetworkConnectionToClient conn, double time)
        }
    }

    connectionToLastHeartbeatMap[conn] = time;
    {
        public float GetSecondsBetweenHeartbeats() => secondsBetweenHeartbeats;
    }

    public bool IsEnableInactiveTimeout() => enableInactiveTimeout;

     

    #region InactivePlayerTimeout
}
```
