ADVENT CALENDAR
LiteNetLibとMessagePackで行うリアルタイム通信
By wraikny
こんにちは
これは AmusementCreators 2020 アドベントカレンダー の 15日目の記事です。
オンラインでリアルタイムに協力とか対戦するゲームをつくりたいというモチベーションから、この記事を書きます。
動いている様子
ACAC2020 15日目 サンプル動画 - YouTube
これは実際にサーバーのプログラムをAmusementCreatorsのVPSに設置し、手元のクライアントから接続して同期している様子です。 pingは18ms前後で、ほとんどラグを感じずに操作できています。
サーバーはしばらく動作させておきます。 実行してみたい場合は、以下のURLからダウンロードできます。 なお、macOSでは実行権限を付与した上でコマンドラインから実行する必要があります。
はじめに
ライブラリの一覧
- LiteNetLib
- RUDPを扱う純C#製ライブラリで、レイテンシシミュレーション、NAT超えのオプション、暗号化処理を入れる仕組みなどがあって便利。
- MessagePack-CSharp
- MessagePackのC#実装。処理が極めて高速で、LZ4での圧縮やセキュリティのオプションも可能。
描画にはゲームエンジンAltseed2を使います。
- Altseed2(.NET)
- AmusementCreators中心に開発中のOSSゲームエンジン。
- MessagePack.Altseed2
- MessagePackのAltseed2向け拡張(構造体をサポート)。
LiteNetLibのレイテンシシミュレーションはDEBUGビルドでのみ動くため、nugetではなくsubmoduleで追加します。
Altseed2はnuget経由ではなく最新のビルドを利用します。(GitHub Actionsから取得できます)
MessagePack.Altseed2はlocal-altseed2ブランチをsubmoduleで利用して、ローカルのAltseed2を参照させます。
サーバー側もAltseed2の構造体を共通で使いたかったので、Altseed2のdllとMessagePack.Altseed2を参照しています。
今回の実装を行ったリポジトリはこちらです。
前提条件とか
- Reliable User Datagram Protocol: 信頼性をもたせたUDP。TCPではオーバーヘッドが大きすぎる場合に使われます。
- MessagePack: 効率の良いバイナリ形式のオブジェクト・シリアライズフォーマットです。
LiteNetLibにも簡易的なバイナリシリアライザは乗っていますが、MessagePackのほうが様々な面で扱いやすいのでこちらを利用します。(APIの使いやすさや性能やUnion等)
リアルタイム通信・オンラインゲームの実装には大まかに以下の3種類があると思います。
種類 | メリット | デメリット |
---|---|---|
クライアントが直接通信(P2P) | サーバーを介さないので速い | チートしやすい, N^2の通信が発生, 整合性を取りづらい |
通信の中継を行うサーバー | 人数が増えても通信回数を抑えられる | チートしやすい, 整合性を取りづらい |
ゲームロジックを持つサーバー | チート対策が比較的楽, 整合性が取れる | サーバーの処理が大きい |
今回は規模が小さいものを楽に作りたいので、サーバーサイドにゲームロジックを持たせることにします。
オンライン通信のあるゲームでは、遅延によって状態に整合性が取れない場合があります。
例えばほぼ同じ瞬間に2人のプレイヤーが同じアイテムを拾ったとき、それぞれにプレイヤーには相手がそのアイテムを拾ったという情報が伝わっていないので、単純な実装ではズレが発生してしまいます。 整合性を取るために状態を巻き戻したり、確認の処理を挟む必要が出てきます。
P2Pやリレーサーバでは各クライアントがゲームロジックを処理するため、それぞれのクライアントで処理結果が同じになるように遅延を考慮して整合性を保つ必要があります。
サーバーにゲームロジックがあればサーバーの状態が正しいと考えて良いので同期ズレを考慮する必要が少なくなるはずです。また、クライアントは操作のみを送るとすれば、不正なデータを送信するようなチートはしずらくなります(ゲーム内容によってはbot対策などはまた別途必要ですが)。
諸々の解説
MessagePackについて
- classやpropertyのアクセシビリティは全部
public
にしましょう、注意(分かりづらい例外が発生して死ぬ) - MessagePackAnalyzerを入れてVisualStudioを使うことで、Attributionのつけ忘れは警告してくれるようになります。
readonly
なfield
、setter
には、デシリアライズ用のコンストラクタを定義しましょう。(SerializationConstructor)- MessagePackはinterfaceを利用した
Union
に対応していますが、その場合はMessagePackSerializer.Serialize<IHoge>(fuga)
とinterfaceを指定してシリアライズを行いましょう(具体的なclassとしてシリアライズを行うと、Union
としてのデシリアライズが失敗します) No hash-resistant equality comparer available for type: Altseed2.Vector2I
:Altseed2.Vector2I
をDictionary
のkey
にしてMessagePackに突っ込んだらなんか例外が出た、悲しい。
幸い有名でよく使われているライブラリなので、エラーメッセージでググるとGitHubのissueがヒットしてくれます。
LiteNetLibについて
DeliveryMethod
DeliveryMethod
の種類としておおよそ以下のものがあります。
ReliableOrdered
: 送信保証・順序保証あり。ReliableSequence
: 最新のデータのみ送信保証あり、遅れて来たデータは無視する。(最新情報のみ欲しい場合に使う)ReliableUnordered
: 送信保証のみあり。- 上記の
Reliable
が外れたもの: 送信保証なし。
この辺の用語の説明は以下のサイトに詳しく書かれていました。(別のライブラリですが)
NetPeer.Send
ではDeliveryMethod
に加えてchannnel: byte
を指定できて、複数のDeliveryMethod
を使い分けたい場合に便利です。ただし、NetManaber.ChannelsCount
を事前に指定する必要があります。
注意点としては、送信するヘッダーサイズ+データサイズがMTUサイズを超える場合に、ReliableOrdered
とReliableUnordered
では分割して送信してくれますがそれ以外では例外が発生するということです。LiteNetLib/LiteNetLib/NetPeer.cs#L533
また、全く制御せず単にUDPでおくるためのメソッド(NetManager.SendUnconnectedMessage
)なども用意してあるようです(これはLiteNetLibをつかう意味は特にないですけど)
SimulateLatency, SimulatePacketLoss
LiteNetLibには、レイテンシシミュレーションを行うNetManager.SimulateLatency
、パケットロスシミュレーションを行う NetManager.SimulatePacketLoss
のオプションがあります。これらはLiteNetLib自体のDEBUG
ビルドでのみ有効なので、利用したい場合はsubmoduleとして追加する必要があります。
NatPunchEnabled
LiteNetLibには標準でNAT超えのための機能があり、このオプションを有効にすることでいい感じになりそう(自分は未検証)。
通信の仕方
この辺はいろいろ最適化の余地もあると思いますが、とりあえず今回の実装の説明です。
- 各クライアントは操作のたびに、その操作を表すデータを送信します。
- サーバーはデータを受信するとゲームステートの更新を行い、差分が生じた場合は更新されたというフラグを立てます。
- サーバーは数msごとにゲームステートに更新があったかどうかを確認し、更新されていたら各クライアントに現在のゲームステートを送信します。
- クライアントがゲームステートを受信すると、それを画面に反映します。
気をつけるポイントは、サーバーが各クライアントにデータを送る場合に、クライアントの操作を受信するたびに送信はしないで、数msごとに全クライアントに送信するという点です。 人数が大きくなった際に通信回数がN^2で肥大する原因となるので、気をつけると良いらしいです。
これは以下の記事などを参考にしました。
本来はクライアント側の送信も数msごとにまとめて送信するほうが通信回数が減って良いかもしれませんが、その場合に発生する遅延のことを考慮するのが面倒だったので今回は即時に送信することにしました。
コードを探索する
ここからは実際のコードをいくつか追ってみます。
フォルダ構成
-
src/Shared 各プロジェクトで共通で利用するコードを配置したディレクトリ。共通の設定・メソッドの他、通信で使用するオブジェクトの定義などが含まれます。
-
src/ACAC2020_15.Client ゲームクライアントのディレクトリ。
-
src/ACAC2020_15.Server ゲームサーバーのディレクトリ。
LiteNetLib、MessagePackのオプションなど
Shared.Settings
LiteNetLibに関する設定をまとめて書きました。
Shared.Utils
MessagePackOption
を定義してあります。
クライアントとサーバーそれぞれのProgram.Main
の先頭でMessagePackSerializer.DefaultOptions
に設定しています。
なお、ここではMessagePack.Altseed2のResolver
の追加、ネットワーク越しに送られてくるバイト列をデシリアライズする際の脆弱性を考慮したセキュリティの設定、LZ4を使用した圧縮を指定しています。
MessagePack-CSharp、本当に使いやすくて素晴らしいです
LiteNetLib関連
Server.Client
サーバー側でクライアント側の情報を管理するためのクラスです。
今回は利用しませんでしたが、各クライアントごとに平均のレイテンシを計算して保持しています。
Server.Server
サーバーのクラスです。
INetEventListener
を実装することで、NetManager
に登録してイベントが起こった際に処理を記述できます。
Client
にはId : ulong
を持たせていて、Dictionary<int, Client>
でNetPeer
のId: int
と対応させています。
これはLiteNetLibの内部で接続解除されたNetPeer
のインスタンスが使い回されているようだったので、Server
ではClient.Id
を別で管理しています。
OnPeerConnected
で、クライアントにClient.Id
を送信しています。
他は基本的にはLiteNetLibのExampleに従いました。
また、今回はサーバーに唯一のGameState
をもたせてOnPeerConnected
でGameState.PlayerEnter
を、OnPeerDisconnected
でGameState.PlayerExit
を呼んでいますが、
サーバーに複数のルームのようなものをもたせたい場合はメッセージのやり取りをした上で入退室を行うと良いです。
OnNetworkReceive
ではメッセージを受け取った際の処理を書いています。
ここでMessagePackを利用したデシリアライズと型でのパターンマッチを行って、GameState
のメソッドを呼び出して更新します。
IClientMsg
のUnion
に関しては後述しますが、これが簡単にできるのがMessagePackのいい点です。
Update
メソッドはServer.Program.Main
からループの中で呼び出しています。
ここが前でも述べたポイントで、更新が発生したか確認してすべてのクライアントへ送信することで回数を抑える工夫です。
Client.NetworkNode
Server.Server
と同様に、INetEventListener
を実装しています。
OnNetworkReceive
でメッセージを受け取った処理を記述しているのも同様です。
一応Altseed2.Node
を継承して実装しました。
Message
Shared.IServerMsg
サーバーからクライアントに送信するメッセージを表しています。
MessagePackのUnion
という機能を使うことで、interfaceやabstract classを対象に事前に指定したクラスでシリアライズ・デシリアライズが可能になります。
クライアントに自身のId
を伝達するIServerMsg.ClientId
クラスと、現在のGameState
を送信するIServerMsg.SyncGameState
を定義しました。
なお、今回の実装ではGameState
を直接送信していますが(楽なので)、それによってデータサイズが肥大化することが懸念される場合には差分のデータのみを送信したり、ゲームの実装によってはプレイヤーの周囲外をカリングすることでデータサイズを削減できると思います。
Shared.IClientMsg
クライアントからサーバーに送信するメッセージを表しています。
実装によってはその他のメッセージが増えることも考えて、IPlayerAction
という型で具体的な操作を定義しています。
Shared.IPlayerAction
プレイヤーがゲームで行う操作を表しています。
ここではMove
とCreateBlock
とBreakBlock
の3種類の操作があり、Move
では列挙型のDirection
のみを持っているため、クライアントは不正に位置情報を書き換えるなどが難しくなっています。
ただし今回の実装では更新回数のバリデーションなどは行っていないので1フレームに何度も情報を送信できてしまいますが、実際はClient
に送信間隔などの情報をもたせて Server
のOnNetworkReceive
などでバリデーションを行うと良いと思います。
Shared.GameState
ゲームの状態を管理します。
ゲームの内容が小さいので大したことはしていません。
GamePlayer
とGameBlock
の管理を行っています。
MessagePackで送信するので実装はShared
以下に置いていますが、実際の更新はサーバーでしか行わないのでpartial
を使って更新のためのメソッドはServer
以下に置いてもいいかもしれません。
ゲームの内容によっては、クライアントとサーバー両方で更新した後にサーバーから届いた情報を適宜反映させる必要もあるかもしれません。
クライアント側のIO(入力・表示)
Client.PlayerInputNode
プレイヤーの入力を管理するクラスです。
Network関連のメソッドへの参照をもたせるとコードが汚くなりそうだったので、event
を用意してIPlayerAction
のインスタンスを流すようにしました。
いろいろ
- BlockViewNode ブロック表示用のノード
- PlayerViewNode プレイヤー表示用のノード
- OtherPlayerNode
自分以外のプレイヤーを表すためのノード。今回は
PlayerViewNode
のラップでしかないが、特定の処理を行いたい場合を考えて用意してある。 - SelfPlayerNode
自身を表すためのノード。同様に
PlayerViewNode
とPlayerInputNode
のラップ
Client.SceneNode
Client
側の全体の処理を管理しているのノード。
OnPlayerInput
で、プレイヤーが入力を行った際にメッセージをサーバーへ送信している。
OnReceiveGameState
で、サーバーから最新のGameState
が送られてきたとき他プレイヤーとブロックの追加・更新・削除を行っている。
以上のコードからわかるように、クライアント側ではGameStateに対する一体の更新処理を行わず、サーバーから受け取った情報のみから画面の更新を行っています。
しかし、動画のように実際にサーバーを介した通信を行ってみても、この程度の規模とゲーム性であればラグは感じずにプレイできていました。 もちろん、日本国内での通信のみ、かつ光回線で有線接続しているためレイテンシが少ない、というのもあるとは思いますが。
おわりに
考察など
今回はとにかく楽に動くものが作りたかったのでこんな感じのアプリケーションを作ってみましたが、例えば座標が連続的になるだけでラグや通信間隔が気になり始めるかもしれません。
人数がもっとに増えれば、当然通信回数をもっと抑える必要が出てくると思います。
ラグをごまかすための手法としては、アニメーションの初動を工夫したり、座標の線形補間を行ったり、データの送信時にゲーム開始時から現在の時刻を一緒に送信するなどの方法があるようです。
また、ゲーム性の面でもオンラインに向いているゲーム・向かないゲームというのはありますよね。
例えばシューティング系のゲームでは、弾の座標は速度とレイテンシをもとに補間しやすいです。
少人数の対戦パズルでお邪魔を送り合うようなゲームでは、ラグの補間はおじゃまのタイミング程度な気もしますし(比較的)楽に実装できそうです。
一方で、プレイヤー同士に当たり判定があったり押し合ったりするゲームでは、座標を適切に補間したり数フレーム前までの情報を保持し続けるなど、かなりの工夫が必要そうですね。
気持ち
LiteNetLibとMessagePackを使って、かなり手軽かつ快適にリアルタイム通信が実現できました。 オンラインゲームを作りたい方は今回のコードを参考にしたり、逆に通信回数の削減やラグをごまかす手法について記事を書いてもらえたら嬉しいなと思っています。
また、P2PのためのマッチングサーバーなどはREST APIベースで比較的楽に作れそうですし、年が明けたら着手の機運もあります。
ところで……
私が制作したゲーム RouteTiles はご存知でしょうか?
タイルをつなげて消すパズルゲームで、複雑につなげて一気に消す爽快感が楽しく、オンラインランキングで得点を競うのがかなりアツいです! UIにもこだわって作ったので、ぜひ遊んでみてください。
実は今回のLiteNetLibの調査は、このRouteTilesに対戦機能を入れたいというモチベーションから始めたものでした。 年明け後から本格的に着手する予定なので、応援していただけると嬉しいです!
DLはこちら: 夏休みゲームジャム成果発表!!新作大公開SP
SHARE THIS POST
Tweet