Reconnecting
以下文件將詳細說明如何重新連接到正在運行的 Quantum 會話。基本流程已實現在 Quantum SDK 附帶的 Quantum 選單中。
重新連接過程包含兩個主要部分:如何重新加入 Photon Realtime 房間,以及如何處理 Quantum 模擬。
檢測斷線
- Photon Realtime 管理的 Socket 連接報告錯誤 RealtimeClient.CallbackMessage.ListenManual<OnDisconnectedMsg>(OnDisconnect),且原因不是DisconnectCause.DisconnectByClientLogic。
- Quantum 伺服器檢測到錯誤並斷開客戶端連接,註冊 QuantumCallback.SubscribeManual<CallbackPluginDisconnect>(OnPluginDisconnect)以捕獲這些情況。
- 移動應用程式失去焦點。大多數情況下,線上會話無法恢復,客戶端應執行完整的重新連接。
測試斷線的模擬網路錯誤
- 在 Unity 編輯器中,只需點擊 Play 停止並重新啟動應用程式。
- RealtimeClient.SimulateConnectionLoss(true)將停止發送和接收,並在 10 秒後導致- DisconnectCause.ClientTimeout斷線。
- 使用外部網路工具(例如 clumsy)封鎖遊戲伺服器端口。
Clumsy:
Filter  (udp.DstPort == 5056 or udp.SrcPort == 5056) or (tcp.DstPort == 4531 or tcp.SrcPort == 4531)
Drop    100%
Photon Realtime快速重新連接
要返回房間,可以使用MatchmakingExtensions進行重新連接和重新加入:
C#
RealtimeClient Client = await MatchmakingExtensions.ReconnectToRoomAsync(arguments);
此方法將嘗試直接連接到遊戲伺服器並使用MatchmakingArguments上配置的MatchmakingReconnectInformation數據重新加入房間,例如區域、應用版本、房間名稱等。
重新加入房間將為客戶端分配相同的 Photon Actor ID。
重新加入房間也可以在重新連接到主伺服器後執行:
C#
RealtimeClient.ReconnectToMaster()
// ..
public void IConnectionCallbacks.OnConnectedToMaster() {
    _client.OpReJoinRoom(roomName);
}
重新加入操作僅在客戶端尚未離開房間時有效(見下一節 PlayerTTL)。
要求:PlayerTTL
房間內的客戶端通常是active。它們會在以下情況下變為inactive:
- 10 秒(預設)未回應伺服器後
- 調用RealtimeClient.Disconnect()後
- 調用RealtimeClient.DisconnectAsync()後
- 調用RealtimeClient.LeaveRoomAsync()後
現在有兩種選項:
A) 房間執行玩家離開邏輯 (PlayerTTL為 0,預設)
B) 玩家被標記為inactive,並在PlayerTTL毫秒內保持該狀態,然後執行離開房間程序。PlayerTTL值需要在創建房間時在RoomOptions中明確設置。通常 20 秒(20000 毫秒)是一個不錯的起始值。
快速重新連接允許客戶端在仍為 active(10 秒超時之前,Realtime.Client.OpJoinRoom()不允許)和inactive(在 PlayerTTL 超時期間)時返回房間。
當客戶端成功重新加入後,IMatchmakingCallbacks.OnJoinedRoom()會被調用。
示例選單實現了一個選項來檢查(QuantumMenuUIMain.RunReconnection())。
要求:RoomTTL(等待快照)
當房間檢測到所有客戶端都為inactive時,它會立即關閉。為防止這種情況,請設置 RoomOptions.EmptyRoomTTL。這在您的房間只有少量玩家且所有玩家同時遇到連接問題的概率較高時非常重要。因為需要有人發送快照,這 僅 在使用自訂伺服器插件和伺服器端快照時可靠。
考慮以下情況:在一個雙人線上遊戲中,一名玩家正在重新連接或遲加入並等待快照,而另一名玩家開始遇到連接問題。快照永遠不會發送,玩家將卡在等待狀態。
這個問題由SessionRunner.StartAsync和SessionRunner.WaitForStartAsync處理,考慮到SessionRunner.Arguments.StartGameTimeoutInSeconds。
要求:Photon UserId
Photon Realtime: 大廳與匹配 | UserId 與朋友
在 Photon 中,玩家使用唯一的 UserID 進行識別。要使用重新加入返回房間,UserId 必須相同。無論 UserId 最初是明確設置還是由 Photon 設置都不重要。
一旦進入房間,UserId 對 Quantum 不重要,因為它使用不同的 ID 來識別玩家(見 Quantum ClientId 部分)。
Photon UserId 可以是..
- 由客戶端在連接時設置 (AuthenticationValues.UserId)
- 如果留空,則由 Photon 設置
- 或由外部驗證服務設置
關於 Photon ID 的背景資訊:
- Photon Actor Number(也稱為 actor id)在當前房間中識別玩家,並在該上下文中分配。離開並重新加入房間的客戶端將獲得新的 actor id。成功的 OpRejoinRoom() 或 ReconnectAndRejoin() 將保留 actor id。Quantum 提供了一種回溯 actor id 並將其與玩家- Frame.PlayerToActorId(PlayerRef)匹配的方法。但請記住,它們可能會因玩家離開和重新加入(非重新加入)而改變。
- Photon Nickname是一個 Photon 客戶端屬性,在房間中傳播以了解其他客戶端的更多資訊。與 Quantum 無關。
可能的錯誤:ReconnectAndRejoin 返回 False
當前的連接處理程序RealtimeClient缺少執行重新連接所需的相關數據。運行您的預設連接序列並嘗試以常規方式加入或重新加入房間。
另見Reconnecting After App Restart章節。
可能的錯誤:PlayerTTL 超時
當在 PlayerTTL 超時後重新加入時,會拋出ErrorCode.JoinFailedWithRejoinerNotFound。
這也意味著我們已連接到 MasterServer,可以使用 JoinRoomAsync()加入房間。
可能的錯誤:驗證令牌超時
驗證票據在 1 小時後過期。在 Quantum 遊戲會話期間,它會在到期前自動刷新(Photon Realtime: 加密 | 令牌刷新)。如果您的常規遊戲會話較長,並且您希望支持大約 20 分鐘後重新連接的玩家,則需要處理此錯誤。解決方法是重新啟動預設連接序列並嘗試重新加入房間。
C#
public void OnDisconnected(DisconnectCause cause) {
    switch (cause) {
        case DisconnectCause.AuthenticationTicketExpired:
        case DisconnectCause.InvalidAuthentication:
            // Restart with your default connection sequence
        break;}}
可能的錯誤:連接仍不可用
當然,連接可能仍然受阻或發生其他錯誤。在每種情況下,都會調用 IConnectionCallbacks.OnDisconnected(DisconnectCause cause)。
應用重啟後重新連接
MatchmakingReconnectInformation對象緩存了與重新加入操作相關的數據,這些資訊在重新啟動應用程式時可能會丟失。
在這種情況下,必須從頭開始重新啟動連接,同時重用相同的 UserId, FixedRegion和AppVersion。到達主伺服器後,使用 Rejoin()或Join()返回房間。
由於丟失了連接緩存,重新加入可能會因 ErrorCode.JoinFailedFoundActiveJoiner而失敗,因為伺服器尚未註冊斷線(10 秒超時)。在這種情況下,請重試直到重新加入成功或發生其他錯誤。
將 Photon UserId 保存到 PlayerPrefs 當然可以替換為自訂驗證。
也可以將快照存儲並加載到 PlayerPrefs 中,這對於玩家數量非常少的遊戲可能很有用。要將二進制數據作為string存儲在 PlayerPrefs 中,請使用base64編碼和解碼。
不同的主伺服器
ReconnectAndRejoin()和ReconnectToMaster()都防止了一種邊緣情況,即客戶端通過雲重新連接後最終連接到不同的主伺服器。原因包括:
- 一個應用有多個集群
- 主伺服器已被替換(輪換)
- 最佳區域 ping 有新結果
其他 Photon Realtime 主題
這些功能對重新連接不重要,但屬於演示選單示例的一部分,因此我們也在此介紹。
最佳區域摘要
區域 ping 會定期強制執行,但為了確保玩家不會因錯誤的 ping 結果而卡住,實現失效機制可能是明智的(例如,如果 ping 高於閾值,則每兩天清除 BestRegionSummary)。此外,玩家可能會旅行到世界其他地方,這時需要新的 ping 來找到最近的區域。
AppVersion
在演示選單示例中,玩家可以通過QuantumMenuViewSettings選擇AppVersion。我們通過AppSettings提供的AppVersion會將相同 AppId 的玩家群體分成不同的組。連接到相同 AppId 但不同 AppVersions 的玩家將完全找不到彼此。
這在運行多個遊戲版本時以及在開發過程中非常有用,可以防止其他客戶端(具有不同的代碼庫並會立即導致遊戲不同步)加入開發人員運行的遊戲。
進一步閱讀
- Photon Realtime: 分析斷線 | 快速重新加入 (ReconnectAndRejoin)
- Photon Realtime: 已知問題 | 移動背景應用
- Photon Realtime: .NET 客戶端 API | LoadBalancingClient
重新連接到正在運行的 Quantum 遊戲
Quantum ClientId
ClientId是客戶端和伺服器之間的秘密。其他客戶端永遠不會知道它。它在啟動 QuantumRunner 時傳遞。
C#
var sessionRunnerArguments = new SessionRunner.Arguments {
        ClientId = Client.UserId,
        //Other arguments are needed
      };
var runner = (QuantumRunner)await SessionRunner.StartAsync(sessionRunnerArguments);
無論是作為新的 Photon 房間 actor 加入還是重新加入,重新連接的客戶端都通過其ClientId識別,並將被分配給之前相同的玩家索引(如果該槽位未被其他玩家佔用)。簡而言之:玩家在重新連接時必須使用 相同的ClientId。
Quantum 不會讓客戶端在另一個具有相同ClientId的active玩家仍在房間內時啟動會話,並等待斷線超時(10 秒):
DISCONNECTED: Error #5: Duplicate client id
這就是為什麼 **需要ReconnectAndRejoin()**來從短期連接丟失中恢復。
進一步閱讀
重新啟動 Quantum 會話
斷線後,QuantumRunner和DeterministicSession 不 再可用,必須銷毀並重新創建。
當客戶端加入或重新加入運行 Quantum 遊戲的房間後,需要重新啟動 QuantumRunner。模擬將暫停,直到從其他客戶端收到快照。然後將追趕並同步到最新的遊戲時間。
大致流程:
- 檢測斷線,銷毀 QuantumRunner
- 重新連接並重新加入房間
- 通過調用SessionRunner.StartAsync()重新啟動 Quantum
要停止並銷毀 QuantumSession,請調用:
C#
QuantumRunner.ShutdownAll(true);
僅 在您位於 Unity 主線程上時使用immediate:true 調用此方法,並且 永遠不要從 Quantum 回調內部調用。使用immediate:false或手動延遲調用,直到它被 Unity 更新調用拾取。
演示選單示例展示了如何啟動新遊戲、遲加入正在運行的遊戲。在 QuantumMenuUIParty.ConnectAsync()中,我們通過評估ConnectResult檢測到遊戲已經啟動。
EntityViews與UI
遲加入和重新連接的玩家對遊戲的構建方式提出了很高的要求。它需要支持從任何時間點啟動遊戲,並可能重用實例化的預製件和 UI,以及在任何可能時刻停止和清理遊戲。副作用包括高加載時間、新場景中出現不需要的 VFX 和動畫、卡在 UI 過渡中等。
如果您希望保持QuantumEntityViewUpdater和QuantumEntityViews存活以重用它們,則需要手動停止它們的更新,重新匹配新的QuantumGame實例,訂閱新的回調等。
另一方面,Quantum 的處理非常簡單:關閉 runner,啟動 runner。
事件
客戶端不會收到玩家加入或重新加入之前引發的事件。遊戲視圖應該能夠通過輪詢模擬的當前狀態完全初始化/重置自己,並使用未來事件/輪詢來保持更新。
SetPlayerData
為重新連接的玩家調用QuantumGame.AddPlayer(RuntimePlayer data)是可選的。這取決於您的模擬中的角色設置邏輯是否需要此操作。
StartParameters.QuitBehaviour
當執行 Quantum 關閉序列(QuantumRunner.ShutdownAll)時,QuantumNetworkCommunicator 類將可選地執行房間離開操作或斷開 LoadBalancing 客戶端。在 QuantumRunner.StartParameters 上設置為QuitBehaviour.None以自行處理。
遲加入與夥伴快照
Quantum 遊戲快照是一個平台獨立的數據塊,包含所有輸入已接收後遊戲的完整狀態。Quantum 模擬可以從快照啟動,並從該狀態無縫繼續。
客戶端可以在模擬仍在運行時創建自己的快照(本地快照),可以從其他客戶端請求快照(夥伴快照),也可以從運行模擬的自訂伺服器插件發送快照。
從快照啟動或重新啟動非常方便,並由 Quantum 提供開箱即用的支持。否則,遲加入或重新連接的客戶端將不得不從頭開始遊戲會話,並快速前進伺服器發送的輸入歷史記錄,這可能導致客戶端應用在趕上之前無法使用,而且伺服器上存儲的輸入歷史記錄限制在約 10 分鐘。
夥伴快照過程在任何客戶端啟動其 QuantumRunner 時自動啟動(無論客戶端是首次啟動會話、遲加入還是重新連接)。會話將進入暫停模式 DeterministicSession.IsPaused並請求快照。成功的遲加入將記錄以下消息:
Waiting for snapshot. Clock paused.
Detected Resync. Verified tick: 6541
客戶端在初始啟動 5 秒後連接時會請求夥伴快照。
伺服器使用負載平衡機制來決定請求哪個客戶端發送夥伴快照,以避免對單個客戶端造成過大負擔。
快照過程中的錯誤將通過Disconnect消息轉發給客戶端(例如,快照等待狀態將在 15 秒後超時):
| 名稱 | 描述 | 
|---|---|
| 錯誤 #13: 快照請求失敗 | 對於遲加入或重新加入的客戶端,當請求快照時,房間/遊戲中沒有其他客戶端可以發送夥伴快照。 | 
| 錯誤 #51: 快照下載超時 | 伺服器無法在預設的 20 秒超時內發送所有所需的快照片段。 | 
| 錯誤 #52: 快照上傳超時 | 請求的夥伴快照無法在預設的 10 秒超時內上傳。 | 
| 錯誤 #53: 快照上傳錯誤 | 上傳的夥伴快照包含錯誤。 | 
| 錯誤 #54: 快照上傳斷線 | 由於上傳夥伴快照的客戶端斷線,遲加入被中斷。 | 
從快照啟動遊戲時,與遊戲啟動過程有 一些差異:
- 代替CallbackGameStarted,執行回調CallbackGameResynced。
- System.OnInit()在收到快照之前調用。
本地快照
作為可選的重新連接策略,可以保存最後驗證的 tick 的本地快照,並在啟動新的QuantumRunner時使用。這在預期離線時間較短時效果最佳。本地快照通常更節省頻寬且更快。
指南
Quantum 對本地快照接受的時間施加了嚴格的限制,因為從過於舊的快照啟動可能會降低用戶體驗。
預設情況下,伺服器不接受超過 10 秒 的本地快照,而是請求夥伴快照。該過程透明地工作,從客戶端的角度來看,唯一的區別是接收到的快照年齡。
對於玩家數量較少的遊戲(例如 1 對 1),沒有其他線上客戶端可以提供夥伴快照的概率很高。這些類型的遊戲通常需要使用EmptyRoomTTL值,Quantum 將本地快照接受時間延長到 EmptyRoomTTL,但最多 兩分鐘。
工作流程
- 檢測斷線
- 獲取快照
- 關閉 QuantumRunner
- 快速 Photon 重新連接
- 使用快照重新啟動 Quantum
本地快照代碼片段
要使用此代碼片段,請創建一個空場景,創建一個遊戲對象並將此腳本附加到它。
至少設置RuntimeConfig上的Map和SimulationConfig。
在RuntimePlayers中添加至少一個條目。
添加QuantumStats預製件以查看 Quantum 模擬是否運行。
點擊Connect開始線上遊戲。點擊 Disconnect停止,等待幾秒鐘,然後點擊 Reconnect,您將看到會話已在進行中且 tick 大於 60。
C#
namespace Quantum.Demo {
  using System;
  using System.Collections.Generic;
  using Photon.Deterministic;
  using Photon.Realtime;
  using UnityEngine;
  using UnityEngine.SceneManagement;
  /// <summary>
  /// A Unity script that demonstrates how to connect to a Quantum cloud and start a Quantum game session.
  /// </summary>
  public class QuantumSimpleReconnectionGUI : QuantumMonoBehaviour {
    /// <summary>
    /// The RuntimeConfig to use for the Quantum game session. The RuntimeConfig describes custom game properties.
    /// </summary>
    public RuntimeConfig RuntimeConfig;
    /// <summary>
    /// The RuntimePlayers to add to the Quantum game session. The RuntimePlayers describe individual custom player properties.
    /// </summary>
    public List<RuntimePlayer> RuntimePlayers;
    /// <summary>
    /// Room keep alive time.
    /// </summary>
    public int EmptyRoomTtlInSeconds = 20;
    RealtimeClient _client;
    string _loadedScene;
    QuantumReconnectInformation _reconnectInformation;
    int _disconnectedTick;
    byte[] _disconnectedFrame;
    bool CanReconnect => _reconnectInformation != null && _reconnectInformation.HasTimedOut == false;
    async void OnGUI() {
      if (_client != null && _client.IsConnectedAndReady) {
        if (GUI.Button(new Rect(10, 60, 160, 40), "Disconnect")) {
          await Stop();
        }
      } else {
        if (GUI.Button(new Rect(10, 60, 160, 40), CanReconnect ? "Reconnect" : "Connect")) {
          await Run();
        }
      }
    }
    async System.Threading.Tasks.Task Run() {
      var connectionArguments = new MatchmakingArguments {
        PhotonSettings = PhotonServerSettings.Global.AppSettings,
        PluginName = "QuantumPlugin",
        MaxPlayers = Quantum.Input.MAX_COUNT,
        // Keep the client connection object, it has cached authentication information
        NetworkClient = _client,
        // Keep an empty room open for a time
        EmptyRoomTtlInSeconds = EmptyRoomTtlInSeconds,
        // Set the stored reconnection information
        ReconnectInformation = _reconnectInformation,
        // Don't let random matchmaking get into this room
        IsRoomVisible = false
      };
      if (CanReconnect) {
        // Switch to reconnecting mode
        _client = await MatchmakingExtensions.ReconnectToRoomAsync(connectionArguments);
      } else {
        _client = await MatchmakingExtensions.ConnectToRoomAsync(connectionArguments);
        // Remove the disconnect information, it would break a new room
        _disconnectedTick = 0;
        _disconnectedFrame = null;
      }
      // Load the map if AutoLoadSceneFromMap is not set
      if (QuantumUnityDB.TryGetGlobalAsset(RuntimeConfig.SimulationConfig, out Quantum.SimulationConfig simulationConfigAsset)
        && simulationConfigAsset.AutoLoadSceneFromMap == SimulationConfig.AutoLoadSceneFromMapMode.Disabled) {
        if (QuantumUnityDB.TryGetGlobalAsset(RuntimeConfig.Map, out Quantum.Map map) == false) {
          throw new Exception("Map not found");
        }
        using (new ConnectionServiceScope(_client)) {
          await SceneManager.LoadSceneAsync(map.Scene, LoadSceneMode.Additive);
          SceneManager.SetActiveScene(SceneManager.GetSceneByName(map.Scene));
          _loadedScene = map.Scene;
        }
      }
      var sessionRunnerArguments = new SessionRunner.Arguments {
        RunnerFactory = QuantumRunnerUnityFactory.DefaultFactory,
        GameParameters = QuantumRunnerUnityFactory.CreateGameParameters,
        ClientId = _client.UserId,
        RuntimeConfig = new QuantumUnityJsonSerializer().CloneConfig(RuntimeConfig),
        SessionConfig = QuantumDeterministicSessionConfigAsset.DefaultConfig,
        GameMode = DeterministicGameMode.Multiplayer,
        PlayerCount = Quantum.Input.MAX_COUNT,
        Communicator = new QuantumNetworkCommunicator(_client),
        // Set the initial tick
        InitialTick = _disconnectedTick,
        // Set the serialized frame
        FrameData = _disconnectedFrame
      };
      // Add a player to the game
      var runner = (QuantumRunner)await SessionRunner.StartAsync(sessionRunnerArguments);
      for (int i = 0; i < RuntimePlayers.Count; i++) {
        runner.Game.AddPlayer(i, RuntimePlayers[i]);
      }
    }
    async System.Threading.Tasks.Task Stop() {
      // Save the serialized frame
      _disconnectedTick = QuantumRunner.DefaultGame.Frames.Verified.Number;
      _disconnectedFrame = QuantumRunner.DefaultGame.Frames.Verified.Serialize(DeterministicFrameSerializeMode.Serialize);
      // Save the reconnect information
      _reconnectInformation = new QuantumReconnectInformation();
      // Set the timeout to empty room ttl
      _reconnectInformation.Set(_client, TimeSpan.FromSeconds(EmptyRoomTtlInSeconds));
      if (string.IsNullOrEmpty(_loadedScene) == false) {
        // Unload a scene if we loaded one
        await SceneManager.UnloadSceneAsync(_loadedScene);
      }
      // Shutdown the runner
      if (QuantumRunner.Default != null) {
        await QuantumRunner.Default.ShutdownAsync();
      }
      // Make sure the client has disconnected
      await _client.DisconnectAsync();
    }
  }
}