再接続
このドキュメントでは、実行中のQuantumセッションへの再接続についての様々な側面に焦点を当てます。基本的なフローは、Quantum SDKに同梱されているQuantumメニューに実装されています。
再接続プロセスは主に2つの要素で構成されていて、「Photon Realtimeのルームへどうやって戻るか」と「Quantumシミュレーションでは何をするか」です。
切断の検知
- Photon Realtimeで管理されるソケット接続は、
RealtimeClient.CallbackMessage.ListenManual<OnDisconnectedMsg>(OnDisconnect)
からDisconnectCause.DisconnectByClientLogic
とは異なる原因のエラーが報告されます。 - Quantumサーバーは、エラーの検知とクライアントの切断を行います。
QuantumCallback.SubscribeManual<CallbackPluginDisconnect>(OnPluginDisconnect)
を購読することで、これらのケースを捕捉できます。 - モバイルアプリがフォーカスを失うと、多くの場合はオンラインセッションが継続できないため、クライアントは完全な再接続を行う必要があります。
ネットワークエラーをシミュレートして、切断のテストをする方法
- Unityエディターでは、再生ボタンを押すだけでアプリケーションを開始/停止できます。
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
のデータ(リージョン・AppVersion・ルーム名など)を使用してルームへ参加します。
ルームへの再参加では、クライアントに同じPhotonのActor
IDが割り当てられます。
ルームへの再参加は、再接続後やマスターサーバーへ接続後でも行うことができます。
C#
RealtimeClient.ReconnectToMaster()
// ..
public void IConnectionCallbacks.OnConnectedToMaster() {
_client.OpReJoinRoom(roomName);
}
再参加命令は、クライアントがルームからまだ退出していない場合のみ動作します。(次のセクションのPlayerTTLを参照)
必要条件:PlayerTTL
ルーム内のクライアントは基本的に「アクティブ」です。「非アクティブ」になるのは、
- サーバー応答が無く10秒後(デフォルト)にタイムアウトした後
RealtimeClient.Disconnect()
を呼び出した後RealtimeClient.DisconnectAsync()
を呼び出した後RealtimeClient.LeaveRoomAsync()
を呼び出した後
などです。ここで2つのオプションがあります。
A) ルームがプレイヤー退出ロジックを実行する(デフォルトでPlayerTTL
が0の場合)
B) プレイヤーが「非アクティブ」にマークされ、ルーム退出ロジックが実行されるまでPlayerTTL
ミリ秒だけその状態が維持されます。PlayerTTL
値は、ルーム作成時にRoomOptions
から明示的に設定する必要があります。値はまず20秒(20000ミリ秒)から試してみると良いでしょう。
高速な再接続によって、「アクティブ」中(10秒後にタイムアウトするまでRealtime.Client.OpJoinRoom()
が使用できない場合)や、「非アクティブ」時(PlayerTTL
時間内)に、クライアントをルームに戻すことができます。
クライアントが正常に再参加すると、IMatchmakingCallbacks.OnJoinedRoom()
が呼び出されます。
サンプルメニューの実装(QuantumMenuUIMain.RunReconnection()
)を確認してみてください。
必要条件:RoomTTL(Waiting For Snapshots)
すべてのクライアントが「非アクティブ」になったことをルームが検知すると、すぐにルームが閉じられてしまうため、RoomOptions.EmptyRoomTTL
を設定してください。これは、ルームのプレイヤー数が少なく、すべてのプレイヤーが同時に接続の問題を起こす可能性がある場合には重要です。スナップショットを送信するには誰かがルームにいる必要があるため、これはカスタムサーバープラグインとサーバースナップショットでのみ確実に動作します。
次のケースを考えてみてください。オンライン上に2人のプレイヤーがいて、1人は再接続/途中参加でスナップショットの受信を待ち、1人は接続の問題が起こっている状況です。この場合、スナップショットは決して送信されないため、プレイヤーは無限に待ち続けることになります。
この問題は、SessionRunner.Arguments.StartGameTimeoutInSeconds
を考慮して、SessionRunner.StartAsync
/SessionRunner.WaitForStartAsync
で処理されます。
必要条件:Photon UserId
Photon Realtime: Lobby And Matchmaking | UserIds And Friends
Photonでは、プレイヤーは一意のUserId
を使用して識別されます。ルームへ再参加するには、UserId
が同じである必要があります。UserId
が手動で設定されたか、Photonで自動で設定されたかは関係ありません。
ルームへ参加した後は、Quantumはプレイヤーの識別に別のIDを使用(Quantum ClientIdのセクションを参照)するため、UserId
は使われません。
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
に、再接続関連のデータがありません。デフォルトの接続シーケンスを実行して、通常の方法でルームへ参加/再参加を試みてください。
「アプリ再開始後の再接続」セクションもご覧ください。
よくあるエラー:PlayerTTLの期限切れ
PlayerTTL
がタイムアウトした後に再参加を試みると、ErrorCode.JoinFailedWithRejoinerNotFound
が発生します。
マスターサーバーには接続しているので、JoinRoomAsync()
でルームへ参加できます。
よくあるエラー:認証トークンのタイムアウト
認証チケットは1時間後に期限切れになります。Quantumゲームセッション中、トークンは切れる前に自動的に更新されます(Photon Realtime: Encryption | Token Refresh)。ゲームセッションが長かったり、約20分後でもプレイヤーの再接続をサポートしたい場合は、このエラーを処理する必要があります。基本的な解決策は、デフォルトの接続ルーチンを再実行して、ルームへ参加し直すことです。
C#
public void OnDisconnected(DisconnectCause cause) {
switch (cause) {
case DisconnectCause.AuthenticationTicketExpired:
case DisconnectCause.InvalidAuthentication:
// デフォルトの接続シーケンスを再実行する
break;
よくあるエラー:接続がまだ利用できない
もちろん、接続がブロックされていたり、他のエラーが発生していたりすることもあります。この場合は、IConnectionCallbacks.OnDisconnected(DisconnectCause cause)
が呼び出されます。
アプリ再開始後の再接続
MatchmakingReconnectInformation
オブジェクトは、再参加命令に関連するデータをキャッシュしますが、この情報はアプリケーション再開始時に失われる可能性があります。
その場合、同じUserId
・FixedRegion
・AppVersion
を再利用して、接続を最初から実行し直す必要があります。マスターサーバーへ接続したら、Rejoin()
/Join()
でルームへ参加し直します。
接続キャッシュは失われているため、ErrorCode.JoinFailedFoundActiveJoiner
で再参加が失敗する可能性があります。これは、サーバーがまだ切断を登録していないためです(タイムアウトは10秒)。この場合は、再参加が成功するか他のエラーが発生するまで、再試行が必要です。
PhotonのUserId
はPlayerPrefsに保存することもできますし、カスタム認証に置き換えることも可能です。
PlayerPrefs内でスナップショットの保存やロードも可能です。プレイヤー数が非常に少ないゲームでは役立つかもしれません。PlayerPrefsにバイナリデータを保存するには、base64
を使用してstring
にエンコード/デコードしてください。
異なるマスターサーバー
ReconnectAndRejoin()
/ReconnectToMaster()
は、Cloud経由で接続し直した際に、クライアントが前回とは異なるマスターサーバーに接続しないようになっています。それでも異なるマスターサーバーに接続してしまう理由は次の通りです。
- 1つのアプリに対して、複数のクラスターが存在する場合
- マスターサーバーが(ローテーションで)置き換えられた
- ベストリージョンのpingで新しい結果を取得した
その他のPhoton Realtime情報
以下の機能は再接続には重要ではありませんが、デモメニューサンプルの一部であるため、ここでカバーしておきます。
Best Region Summary
同じリージョンが選択されることが多いにせよ、プレイヤーが悪い/誤ったリージョンを選択し続けないようにするため、無効化(例:pingがしきい値を超えたら、1日おきにBestRegionSummaryをクリアする)を実装する方が良いかもしれません。また、プレイヤーが世界の他の場所に移動することもあり得るため、その場合は最も近いリージョンを探す必要があります。
AppVersion
デモメニューサンプルでは、プレイヤーはQuantumMenuViewSettings
からAppVersion
を選択できます。AppSettings
に渡すAppVersion
は、同じAppId
のプレイヤーを別々のグループにまとめます。同じAppId
でも、異なるAppVersion
で接続しているプレイヤー同士は、完全に隔離されます。
これは、複数のゲームバージョンを同時に実行したり、開発中に開発者が実行しているゲームに他のクライアント(コードベースが異なるため、ゲームの同期が即座におかしくなる)が参加しないようにする際に便利です。
その他の参考資料
- Photon Realtime: Analysing Disconnects | Quick Rejoin (ReconnectAndRejoin)
- Photon Realtime: Known Issues | Mobile Background Apps
- Photon Realtime: .NET Client API | LoadBalancingClient
実行中のQuantumゲームへの再接続
Quantum ClientId
クライアントとサーバー間でClientId
は秘密で、他のクライアントが知ることはできません。これはQuantumRunner
開始時に渡されます。
C#
var sessionRunnerArguments = new SessionRunner.Arguments {
ClientId = Client.UserId,
// 必要な他の引数
};
var runner = (QuantumRunner)await SessionRunner.StartAsync(sessionRunnerArguments);
新しくPhotonのルームへ参加したか再参加したかにかかわらず、再接続したクライアントはClientId
によって識別され、その間にスロットに他のプレイヤーが入っていなければ、前回と同じプレイヤーインデックスに割り当てられます。簡潔に言えば、プレイヤーは再接続時に**同じClientId
**を使用する必要があります。
同じClientId
の別の「アクティブ」なプレイヤーがルーム内にいる限り、クライアントはQuantumセッションを開始できず、切断のタイムアウト(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
を評価して、ゲームが既に開始されているかどうかを検知します。
エンティティビューとUI
プレイヤーの途中参加や再接続を行うには、ゲームが非常に柔軟に構築されている必要があります。ゲームは任意の時点から開始可能で、プレハブインスタンスやUIを再利用しつつ、いつでも停止やクリーンアップできるようにする必要があります。この副作用として、ロード時間が長くなったり、新しいシーンで不要なエフェクトやアニメーションが表示されたり、UIトランジションがスタックするなどがあります。
QuantumEntityViewUpdater
とQuantumEntityView
を破棄せずに再利用したい場合は、更新されないように手動で停止して、新しいQuantumGame
インスタンスに合わせて、新しいコールバックを購読したりする必要があります。
一方、Quantumの取り扱いは非常にシンプルで、Runner
をシャットダウンして、新しいRunner
を開始するだけです。
イベント
クライアントは、プレイヤーが参加/再参加する前に発生した過去のイベントを受け取ることはできません。ゲームビューの初期化/リセットは現在のシミュレーションステートをポーリングして、そこからイベント/ポーリングを使用して更新を続けてください。
SetPlayerData
再接続プレイヤーに対しては任意でQuantumGame.AddPlayer(RuntimePlayer data)
を呼び出してください。これは、シミュレーションのアバター設定ロジックで必要になるかどうかに依存します。
StartParameters.QuitBehaviour
Quantumのシャットダウンシーケンス実行時(QuantumRunner.ShutdownAll
)、QuantumNetworkCommunicator
クラスは、ルーム退出命令か、LoadBalancingClient
の切断を行います。これを独自に処理したい場合は、QuantumRunner.StartParameters
でQuitBehaviour.None
を設定します。
途中参加とバディスナップショット
Quantumゲームのスナップショットは、検証済み(すべての入力を受信している)ティックの完全なゲームステートを含むデータのBlobで、プラットフォームに依存しません。Quantumシミュレーションは、スナップショットから開始して、そこからシームレスに進行することができます。
シミュレーション実行中に、クライアントは自身のスナップショット(ローカルスナップショット)を作成できます。スナップショットは、他のクライアントからリクエストされたり(バディスナップショット)、シミュレーションを実行しているカスタムサーバープラグインから送信されたりします。
スナップショットからの開始/再開始は非常に便利で、Quantumは標準で提供しています。そうでなければ、途中参加者や再接続クライアントは、ゲームセッションの最初からサーバーの入力履歴をたどって追いつくまでクライアントアプリの描画が停止してしまうでしょう。また、サーバーに保存される入力履歴は最大10分に制限されています。
バディスナップショットのプロセスは、クライアントのQuantumRunner
と同時に自動的に開始されます(初回起動/途中参加/再接続などにかかわらず)。セッションは一時停止モードDeterministicSession.IsPaused
になり、スナップショットをリクエストします。途中参加に成功すると、以下のようなメッセージがログに記録されます。
Waiting for snapshot. Clock paused.
Detected Resync. Verified tick: 6541
初回起動の5秒後に、バディスナップショットがクライアントにリクエストされます。
サーバーは、各クライアントが過負荷にならないような負荷分散メカニズムを使用して、どのクライアントにバディスナップショットをリクエストするかを決定します。
スナップショットプロセス中のエラーは、Disconnect
メッセージ(例:スナップショット待機状態が、15秒後にタイムアウトした)を使用してクライアントに送信されます。
名前 | 説明 |
---|---|
Error #13: Snapshot request failed |
途中参加/再参加したクライアントがスナップショットをリクエストした際に、バディスナップショットを送信できる他のクライアントがルーム/ゲーム内に存在しませんでした。 |
Error #51: Snapshot download timeout |
デフォルトのタイムアウト20秒以内に、サーバーがすべての必要なスナップショットを送信できませんでした。 |
Error #52: Snapshot upload timeout |
デフォルトのタイムアウト10秒以内に、リクエストされたバディスナップショットがアップロードできませんでした。 |
Error #53: Snapshot upload error |
アップロードされたバディスナップショットにエラーが含まれていました。 |
Error #54: Snapshot upload disconnected |
バディスナップショットをアップロード中のクライアントが切断されたため、途中参加が中断されました。 |
スナップショットからゲームを開始する際は、いくつかの違いがあります。
CallbackGameStarted
ではなくCallbackGameResynced
コールバックが実行されます。- スナップショットを受信する前に
System.OnInit()
が呼び出されます。
ローカルスナップショット
再接続方法のオプションとして、最後に確定したティックのローカルスナップショットを保存して、新しいQuantumRunner
を開始する際に使用できます。ローカルスナップショットは一般的に帯域幅が小さく高速なため、オフラインになる時間が短いと予想される場合には最適です。
ガイドライン
ローカルスナップショットを受け付けるタイミングは、Quantumで厳しい制限があります。古すぎるスナップショットから開始することは、ユーザー体験を悪化させる可能性があるためです。
デフォルトでは、サーバーは10秒より古いローカルスナップショットを受け付けず、かわりにバディスナップショットがリクエストされます。プロセスは透過的に動作し、クライアント視点では、受信したスナップショットのティックのみが異なることになります。
プレイヤー数が少ないゲーム(例:1対1)の場合、バディスナップショットを送信できる他のクライアントがオンラインでない可能性が高いため、通常EmptyRoomTTL
値を設定する必要があります。Quantumは、ローカルスナップショット受け付け時間を、EmptyRoomTTL
に延長しますが最大2分までです。
ワークフロー
- 切断を検知する
- スナップショットを取得する
QuantumRunner
をシャットダウンする- Photonの高速な再接続を行う
- スナップショットからQuantumを再開始する
ローカルスナップショット実装例
以下のスニペットを使用するには、空のシーンを作成し、作成したゲームオブジェクトにスクリプトを追加してください。
RuntimeConfig
には、Map
とSimulationConfig
が必要です。
最低1つのRuntimePlayer
を追加します。
Quantumシミュレーションが実行されているかどうかを確認するため、QuantumStats
プレハブを追加します。
Connect
を押してオンラインゲームを開始します。Disconnect
を押して停止した後に、少し待ってからReconnect
を押します。すると、セッションが進行中で、ティックが60より大きいことが確認できます。
C#
namespace Quantum.Demo {
using System;
using System.Collections.Generic;
using Photon.Deterministic;
using Photon.Realtime;
using UnityEngine;
using UnityEngine.SceneManagement;
/// <summary>
/// Cloudサーバーへ接続してQuantumゲームセッションを開始する方法を示すUnityスクリプト
/// </summary>
public class QuantumSimpleReconnectionGUI : QuantumMonoBehaviour {
/// <summary>
/// RuntimeConfigはQuantumゲームセッションで使用され、カスタムゲームプロパティを表します
/// </summary>
public RuntimeConfig RuntimeConfig;
/// <summary>
/// RuntimePlayerはQuantumゲームセッションに追加され、個別のカスタムプレイヤープロパティを表します
/// </summary>
public List<RuntimePlayer> RuntimePlayers;
/// <summary>
/// 空ルームの生存時間
/// </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,
// クライアント接続オブジェクトを保持して、認証情報にキャッシュする
NetworkClient = _client,
// 空ルームを一定時間だけ開けておく
EmptyRoomTtlInSeconds = EmptyRoomTtlInSeconds,
// 保存されている再接続情報を設定する
ReconnectInformation = _reconnectInformation,
// ランダムマッチメイキングでルームへ入れないようにする
IsRoomVisible = false
};
if (CanReconnect) {
// 再接続モードに変更する
_client = await MatchmakingExtensions.ReconnectToRoomAsync(connectionArguments);
} else {
_client = await MatchmakingExtensions.ConnectToRoomAsync(connectionArguments);
// 新しいルームでは問題になるため、切断情報を削除する
_disconnectedTick = 0;
_disconnectedFrame = null;
}
// AutoLoadSceneFromMapが設定されていなければ、マップをロードする
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),
// 初期ティックを設定する
InitialTick = _disconnectedTick,
// シリアライズされたフレームを設定する
FrameData = _disconnectedFrame
};
// ゲームにプレイヤーを追加する
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() {
// シリアライズされたフレームを保存する
_disconnectedTick = QuantumRunner.DefaultGame.Frames.Verified.Number;
_disconnectedFrame = QuantumRunner.DefaultGame.Frames.Verified.Serialize(DeterministicFrameSerializeMode.Serialize);
// 再接続情報を保存する
_reconnectInformation = new QuantumReconnectInformation();
// EmptyRoomTTLのタイムアウトを設定する
_reconnectInformation.Set(_client, TimeSpan.FromSeconds(EmptyRoomTtlInSeconds));
if (string.IsNullOrEmpty(_loadedScene) == false) {
// ロード済みのシーンをアンロードする
await SceneManager.UnloadSceneAsync(_loadedScene);
}
// ランナーをシャットダウンする
if (QuantumRunner.Default != null) {
await QuantumRunner.Default.ShutdownAsync();
}
// クライアントが切断していることを確認する
await _client.DisconnectAsync();
}
}
}
Back to top