自宅で古典的なRTSをゼロから作成するストーリー(パート2:「復活」)記事の終わり:ネットワーク


約1年前、私の記事が出てきました。これはこの記事の「 最初の部分 」と呼ぶことができます。 最初の部分では、できる限り、熱心な開発者の困難な道を整理しました。 これらの努力の結果、自宅でエンジン、デザイナー、その他の最新の開発ツールなしで作成されたRTSジャンル「 Land of Onimode 」のゲームができました。 プロジェクトでは、 C ++Assemblerが使用され、メインツールとして私自身の頭が使用されました。

この記事では、私がどのように「蘇生者」の役割を引き受け、このプロジェクトを「復活させる」ことを決めたかについてお話します。 独自のゲームサーバーの作成には、多くの注意が払われます。

これで記事の終わりです 。始まりはこちらです。

記事の冒頭:ゲームの復活
記事の続き:GUI

ネットワーク


添付の例を分析しなければ、私が話そうとしていることの複雑さを理解できるとは思いません。 しかし、いずれにせよ、この主題に関する一般的な考えを伝えることができることを願っています。 また、私は自分でそのような解決策を考え出すことを別に注意する必要があります。そのため、問題に対する私の解決策の一部が次善となるリスクが常にあります。 私はlibuvネットワークのライブラリを主に「科学的な突く」方法で研究しました。また、ネットワークプログラミングに常に携わっている人々が私の解釈の一部を修正できることも認めています。 そして今、おそらく、私は記事のトピックに戻ります...

最初は、 WinSockライブラリをネットワークに使用したかったのです。 しかし、彼はすぐに考えを変えました。この方法で私が再び完全にWindowsに接続されることは明らかだったからです。 それで、私はインターネットを調べて、 libuvと呼ばれる興味深い解決策を見つけました。 このライブラリは無料です。 WindowsUnixMac OSAndroidで 動作ます 。 また、最新のC ++言語標準を使用する要件など、最新の技術革新を引き付けません。 とにかく、それは純粋なCで書かれており、これは開発者にとってさらなる利点になると考えています。

最初は、2つの問題がありました。

  1. サーバーをゼロから作成したことはないため、一般的なプログラム構造を考え出す必要がありました。

  2. 私の意見では、 libuvのドキュメントには多くの要望があります。 一般的に、私はいつもあなたが非常にまともな製品を作り、多かれ少なかれその機能を説明するのが面倒だと思うのです。 しかし、残念ながら、ほとんどすべての開発者がこれに苦しんでいます。 この分野の怠の最後の段階は、 Doxygenユーティリティなどを使用した自動ドキュメント生成の使用です。これにより、コメントがコードに変換され、クラスと構造間のリンクが自動的に生成されます。 私はおそらく人生に遅れをとっていたかもしれませんが、自分のライブラリに害を及ぼすより良い方法は、あらゆる種類の構造や図から自動的にガベージビンを生成することよりもよくありません。

私の2番目の問題は、 WinSockも使用したことがないという事実によって部分的に補われたため、 WinSockまたはlibuvをどのように扱うかはそれほど重要ではありませんでした。 しかし、 libuvは何かを書き換える必要なくいくつかのプラットフォームを約束してくれたので、私の目には間違いなく勝ちました。

現在、ゲーム開発者はローカルネットワークでプレイする機能を削除することが多いという事実にもかかわらず、私はこのバージョンのネットワークゲームも保持することにしました。 記事の最初の部分で、ローカルネットワークでRTSをプレイする場合、ゲームは各コンピューターで独立して動作し、調整センターは必要ないため、サーバーはまったく必要ないと個人的に説明しました。 そして、本当にそうです。 しかし、私の場合、ネットワークゲームの2つのオプションを取得したいと考えました。インターネット経由とローカルネットワーク経由です。 ローカルネットワークでコンピューター相互作用のピアツーピア方式を使用し始めた場合、このようなソリューションはコードの2番目のブランチを作成します。 そのようなソリューションはより速く動作しますか? おそらくそうですが、LANではデータは通常すぐにターゲットに到達するため、プレイヤーが違いを感じることはまずありません。

その結果、ローカルネットワークにもサーバーを使用すると論理的に判断しました。

アドレスとポートに関する少しの理論


多くの人がおそらく知っているように、インターネットに接続されている世界の各コンピューターには一意のアドレスを割り当てる必要があります(もちろん、NATが使用されているか、同様の場合を除きます)。 このアドレスはIPと呼ばれます。 IPアドレスは4桁で構成され、「人間の形」では次のようになります:234.123.34.18

これらのアドレスで、コンピューターはお互いを見つけます。 ただし、通常、多くのプログラムが同じコンピューターで同時に実行されているため、「ポート」という概念が追加されています。 プログラムはこれらのポートを検出し、それらを介して相互作用を確立できます。 より明確にするために... IPアドレスは、ロシア、ブハロフスカヤ州、ボリショイゴロドゥヒノ村、ulです。 「New Russian Descent」、d.18、および「port」は、特定の人が住んでいるお金のあるアパートの番号です。 アパート(ポート)番号がないと、特定の人(プログラム)に「手紙」を届けることができません。したがって、「ポート」の概念は非常に重要です。 通常、ポートはIPアドレスの後にコロンを介して書き込まれます(例:234.123.34.18:57)。

メッセージは、プロトコルと呼ばれる特別なプログラムによってコンピューター間で配信されます。 インターネット全体が置かれている最も有名なプロトコルはTCPと呼ばれますUDPと呼ばれる別の非常に重要なプロトコルがありますが、それを使用することはやや困難です。

このトピックにあまり詳しくない人のために、その違いを簡単に説明してください。

UDPを使用すると、データをバッチで転送できます。 このデータの断片はデータグラムと呼ばれます。 データグラムは、指定されたIPアドレスとポートに送信できます。 しかし... UDPプロトコルは 「何も約束しません」、つまり 送信されたデータグラムは途中で簡単に失われる可能性があり、この場合、受信者は何も受信しません。 そして、いくつかのデータグラムが送信された場合、それらは異なる順序で来るか、またはいくつかは来ないかもしれません。 ここでは任意のオプションが可能であり、これはUDPの完全に正当な動作です UDPを保証する唯一のことは、データグラムがまだ到着した場合、完全に到着したという事実です。 データグラムの「半分」は来ることができません。

TCPは完全に異なる原理で機能します。 彼は、理解できないデータグラムを運命に翻弄されて未知のネットワーク空間に送信することはありません。最初に受信者との通信チャネルを確立し、このチャネルを通じてデータを明確に送信します。 TCPは、すべての送信データが受信者に届き、送信された正確な順序で到着することを保証します。 データ自体は、固定長のデータグラムの形式ではなく、バイトストリームの形式で提供されます(ファイルにバイトデータを記録するのが良い例えです)。 このアプローチでは、 TCPは送信されたメッセージを必要に応じて部分に分割できることに注意してください。 受信者は最初に送信されたデータの一部のみを受信でき、しばらくすると残りの半分が時間内に到着します。

プログラムとポート間の通信には、いわゆるソケットが使用されます。 ソケットはポートに接続され、ポートとのすべての通信はこのソケットを介して行われます。 ソケットはポートと対話すること以外は何も処理しないため、ポートのないソケットの存在はあまり意味がありません。

プロトコル選択


初期段階では、相互作用のためのプロトコルを選択する必要がありました。 独自のソリューションを作成しない場合、 TCPUDPの 2つのオプションしかありません。 UDPは、ゲームプレイの遅延を許可できない場合に適しています。 通常、ゲーム自体はサーバーで実行されますが、クライアントはゲームの状況の変化に関するデータのみをサーバーから受信します。 このアプローチにより、接続が不十分なプレイヤーを文字通り「無視」できます。 サーバーは遅れているゲームを待つためにゲームを停止しないため、他のすべてのプレイヤーは非常に快適にプレイを続けます。 そのようなゲームの例はCounter Strikeです。

RTSの場合、ゲームプロセスは多くの計算を必要とし、したがって各コンピューターで実行されるためこのアプローチはほとんどの場合不適切です。これらのコンピューターはすべて同じ方法で実行する必要があります。 したがって、他の各コンピューターの各コンピューターは、1つの「ネットワークタクト」でプレーヤーが実行したアクションのリストを常に受信する必要があります。 リストが遅れている場合は、受信されるまで待つ必要があります。 つまり UDPを使用する場合でも、メッセージの配信を自分で制御する必要があります。 したがって、 TCPが選択されました。

私が聞いたように、Starcraft2はRTSジャンルに属しているという事実にもかかわらず、依然としてサーバー上で動作します。 現代の鉄は、この方法を使用するためにそのようなレベルに達している可能性があります。 しかし、私の場合、ネットワークはRTSの 「クラシック」になります。

サーバーは何をしますか?


実際、彼はクライアントからメッセージを受け取るまでほとんど何もしません。 TCPの場合、サーバータスクはポートを開いてリッスンすることです。 クライアントがこのポートへのCONNECT接続を確立する要求を受信した場合、サーバーはACCEPTを実行する必要があります。ACCEPTは新しいランダムポートを開き、クライアントに報告します。 サーバーからランダムなポートを受け取ったクライアントは、独自のランダムなポートを開き、これらのポートでサーバーとクライアントの間に接続が確立されます。 接続は、当事者の1人が接続を閉じるまで、または技術的な理由で接続が切断されるまで存在します。 サーバーは、各クライアントと1つの接続を確立します。 クライアントが互いに送信するすべてのデータはサーバーを通過しますが、ピアツーピアネットワークの場合のように、クライアントからクライアントに直接送信されることはありません。

私のサーバーは次のタスクを実行します。


クライアントは何をしますか?


ゲーム自体はクライアントで行われます。 クライアントは、自分がゲームに一人ではないことを知っており、プレーヤーの行動に関する他の参加者メッセージを送信し、同じメッセージを受信する必要があります。


インターネットサーバーとLANサーバーの違い


逆説的に聞こえるかもしれませんが、LANサーバーはインターネットサーバーよりも複雑です。 なぜそう

インターネットサーバーは別のプログラムとして動作します。このプログラムは一度どこかで起動され、理想的には永久に動作するはずです。 ローカルサーバーは、ホストとして機能するコンピューター上に一時的に作成されます。 このようなサーバーは、別個のプログラムの形ではなく、メインアプリケーションのデータにアクセスする別個のストリームの形でのみ存在します。これは、スレッドの同期によって処理される小規模な問題を伴います。 また、「マルチスレッド」は通常、デバッグの独立したセクションであり、開発者の生活を長く損なう可能性があります。

さらに、クライアントをインターネットサーバーとローカルサーバーに接続する原理は完全に異なることに注意してください。 クライアントがインターネットサーバーに接続できるようにするには、クライアントはIPアドレスとポートを知っている必要があります。 そして、このデータは、プレーヤーによってテキストフィールドに示される必要があります。 また、ローカルネットワークでは、IPアドレスを指定せずに既存のゲームセッションを検出する必要があります。 このアクションは、IPが255.255.255.255である、いわゆる「ブロードキャストリクエスト」によって実現されます。 このアドレスは「ローカルネットワーク全体」を意味しますが、 TCPの場合は最も純粋な形式のUDP機能であるため機能しません。 TCPがブロードキャストアドレスで動作しないのはなぜですか? さて、上で説明したように、 TCPは通信チャネルを確立し、厳密に一対一で通信する必要があります。 そしてここで、「ねえ、ここに誰かいる?」という原則に基づいて、ローカルネットワーク全体を「呼びかける」必要があります。 答えてくれ!」 さて、「いる」人は応答し、ゲームセッションに関する申請者情報を渡さなければなりません。 したがって、ローカルサーバーでUDPを使用する必要があります。

さて、デザートについては...ゲーム中にローカルサーバーを実行しているホストが突然失われた、または何らかの理由でゲームを終了したと想像してください。 あなたはどうなりますか? ゲームを作成した人が残りの前にゲームセッションを終了することにしたとき、ゲームDiablo2で発生する同じことが起こります。 Diablo2では 、残りの不幸なことに、「ホストが利用できなくなりました」というスタイルでメッセージが黒い画面にポップアップ表示されます。実際、それだけです...プレイヤーはゲームから追い出されます。 この動作の理由は、ホストがセッションを離れると、ホストがローカルサーバーを閉じ、すべてのプレーヤーが接続されるためです。 このい動作に対処するため、 DirectPlayにはかつてホスト移行と呼ばれる素晴らしい機能がありました。 一言で言えば、このように見えます...その突然の死に関する情報がローカルサーバーから来ると、残りのクライアントは新しいサーバーを起動して再接続することを決定します。 ローカルサーバーの起動はクライアントで実行できます。これは、セッション内のプレーヤーのリストの一番最初です。 その後、サーバーは、以前にゲームに参加していた全員が接続できるようになるまで待機する必要があります。

ただし、インターネットサーバーには独自の特性もあります。 主な機能は、開発者またはOSのエラーにより、サーバーがクラッシュまたはフリーズする危険性が常にあることです。 この場合、現在ゲームに参加しているすべてのプレイヤーは非常にイライラします。 しかし、この状況では、リリース前にできるだけ多くのバグを特定しようとすることを除いて、何もすることはほとんど不可能です。 しかし、それでも、エラーは複雑なプログラムに残っており、唯一の問題はそれらの発生の可能性です。

しかし、サーバーが突然使用されて「死亡」し、プレーヤーが切断されたと仮定します。 開発者が「曲がった手」に対する呪いをやめた後、プレーヤーは通常、サーバーに再び接続しようとします。 しかし...サーバーが「死んだ」場合、誰かが再起動するまで動作しません。サーバーを監視している人がいない場合、これはすぐに起こります。 その結果、プレーヤーの怒りが自然に増大し始め、深刻な心理的結果につながる可能性があります。 彼はひどく落ち始め、テストは急激に悪化し、実際には、その人は神経衰弱に近くなります。 個人的には、そのような責任を負いたくないので、事前に安全にプレイすることにしました。

インターネットサーバーは独立して動作するべきではありません。サーバーを起動してその動作を監視しようとするコントローラーが必要です。 これには、サーバーに定期的に信号を送信し、応答を受信する必要があります。 信号に応答がない場合、これはサーバーが「クラッシュ」していることを意味します。 この場合、最初にサーバープロセスを完全に強制終了してから、サーバーを再起動する必要があります。 サーバーが実際にどれほど効果的かはわかりませんが、このアプローチは何らかの形でサーバーを保護するはずですが、これが正しい方向への動きであることは間違いありません。

さらに、コントローラープログラムはいくつかの追加アクションを実行できます。 たとえば、サーバーを更新するように彼女に指示できます。 私の場合、次のようになります。

1)たとえば、サーバー上で何かを修正して新しいバージョンのサーバーを作成することにしましたが、誰かがゲームに興味を持っている場合は、誰かがサーバー上で常にプレイしています。 サーバーを再起動すると、プレイヤーはゲームから追い出されますが、これには常に感情的な苦痛が伴います。

2)これを知って、サーバーと監視ユーティリティに「ソフトモード」でサーバーを更新することを教えます。これは次のように実行されます。 管理ユーティリティを介して、新しいサーバーは通常のファイルの形式で転送され、メインサーバーが実行されているコンピューターに届きます。 その後、メインサーバーはリッスンポートを閉じますが、シャットダウンしません。つまり、実際には新しいプレーヤーは参加できなくなり、「すでにここにいる」ユーザーは落ち着いてプレイし続けます。 さらに、このサーバーは、すべてのプレイヤーがサーバーから切断する状況を定期的にチェックし、その後静かに作業を完了します。 この時点で、コントローラープログラムは新しいサーバーの送信ファイルを検出し、古いサーバーと並行して実行します。 リスニングポートは古いサーバーから既に解放されているため、新しいサーバーのリッスンを開始します。これにより、すべての新しいプレーヤーが接続されます。 しばらくすると、古いサーバーは自動的にオフになり、サーバーの新しいバージョンが1つだけ動作します。

これはかなり良いように聞こえますが、残念ながら、この記事を書いている時点では、実際の負荷がかかっている状態でサーバーをチェックできませんでした。 これは、熱心にプロジェクトを開発する際の典型的な問題です。問題が発生したことを単に報告して報告するだけのテスターチームがいない場合です。 しかし、常識は私が正しい方向にすべてをしていることを教えてくれるので、このテーマに関する私の考えを記事に含めました。

Libuvライブラリ関数


このセクションでは、ネットワーキングの開発中に対処しなければならなかったlibuvライブラリの機能について簡単に説明します。 良い記憶は私の強い品質ではないので、少なくとも、この情報は私にとっても役立つ参考になることがあります。

libuvとユーザーの相互作用 、コールバック関数またはコール バック関数の使用完全に基づいています。 どのように機能しますか? たとえば、ユーザーがTCPを介してメッセージを送信したいとします 。 このために、 uv_write()関数が使用されます。これは、もちろん、「送信するもの」および「どのソケットを経由するか」をパラメーターとして受け取ります。 ただし、これに加えて、送信が正常に完了したときに呼び出されるユーザー定義関数のアドレスも指定する必要があります。 発生するイベントを制御できるのは、これらの関数です。 メッセージの受信にも同じことが当てはまりますが、これにはuv_read_start()関数が使用されます。これは、データの別の部分を受信した後に呼び出されるユーザー定義関数も示します。

最も重要な関数はuv_run()関数です。これは基本的にWindowsのメッセージ処理ループのようなものです。ライブラリは、uv_run()内でのみコールバック関数を呼び出します。これは、libuv自体何らかの種類のメッセージをネットワーク経由で受信した場合でも、ユーザーはuv_run()が呼び出されるまでそのことを知らないことを意味します。uv_run()

関数は次のように宣言されます。

int uv_run(uv_loop_t* loop, uv_run_mode mode); 

最初のパラメーターuv_loop_t * loopは、 libuvが個人的なニーズのいくつかに使用する構造体へのポインターです。 この変数を一度作成する必要があり、二度と触れないでください。 たとえば、次のように作成できます。

 uv_loop_t loop; memset(&loop, 0, sizeof(loop)); uv_loop_init(&loop); 

これがループパラメータのすべてであり、 libuv内でどのように使用されるかを知る必要は特にありません。 しかし... 1つの大きなニュアンスがあります。 マルチスレッドプログラムを使用している場合、スレッドごとに独自のループを使用する必要があります 。 私の場合、ゲーム自体に1つのループを作成し、別のスレッドで実行されるローカルサーバーに2つ目のループを作成します。 それに応じて、各スレッドで独自のuv_run()が呼び出されます。

2番目のパラメーターuv_run_mode modeは、 uv_run()関数が機能するモードを決定します。 サーバーの場合は値UV_RUN_DEFAULTを使用し、クライアントの場合はUV_RUN_NOWAITを使用します 。 理由を理解してみましょう。

UV_RUN_DEFAULTパラメーターにより、少なくとも何らかの作業が行われている限り、 uv_run()関数が実行されます。 そして、そのような仕事は、例えば、ポートを聞くタスクです。 つまり ポートをリッスンするソケットが最初に作成された場合、 uv_run()はそのソケットが存在している間は終了しません。 そして、これはサーバーのメインタスクです-クライアントからの接続を待ってそれを確立する。 したがって、 UV_RUN_DEFAULTを使用したオプションはサーバーにとって非常に適切であり、次の行です。

 uv_run(&loop, UV_RUN_DEFAULT) 

多くの場合、プログラムの最後の行です。このサイクルを終了すると、サーバーが単純にシャットダウンするためです。

ユーザーがリスニングソケットを破棄すると、 uv_run(&loop、UV_RUN_DEFAULT)関数は自動的に終了します。

uv_run(&loop、UV_RUN_DEFAULT)関数からの緊急終了には、 uv_stop( 関数が使用されます。これは、パラメーターと同じループを取ります。 このような呼び出しの後、 uv_run()は終了しますが、エラーが返されます。これは、すぐに中断され、まだ何かすることがあることを意味します。 ちなみに、この場合、誰もuv_run()を再度呼び出す必要はありません。

UV_RUN_NOWAITパラメーターは、 uv_run()関数がこれまでに発生したイベントのみを処理するように強制します。 つまり、ネットワークメッセージが受信された場合、コールバック関数が呼び出されます。 その後、 uv_run()関数が完了します。 クライアントは、ネットワークメッセージの交換に加えて、ゲーム自体も処理するため、この動作はクライアントに最適です。 私の場合、 uv_run(&loop、UV_RUN_NOWAIT)をゲームメジャーの開始時に1回、終了時に1回(クロック周波数約60 Hz)呼び出します。 これは、ビートの開始前に受信したメッセージを処理し、ビートの直後に独自のメッセージを送信できるようにするために行われます。

前述のように、 TCPには接続が必要です。 接続要求は、クライアントによって常に特定のIPアドレスとサーバーポートに送信されます。 このために、 uv_tcp_connect()関数が使用されます。

uv_tcp_connect()関数は次のように宣言されます。

 int uv_tcp_connect(uv_connect_t* req, uv_tcp_t* handle, const sockaddr* addr, uv_connect_cb cb); 

最初のパラメーターuv_connect_t * reqは、何らかの構造体へのポインターです 。明らかに、 libuvは何かのために本当に必要です 。 ユーザーのタスクは、この構造を作成して関数に渡すことです。 構造の作成は単純ではありません:

 uv_connect_t connect_data; 

念のため、ゼロを書き込みますが、これは必要ではないようです:

 memset(&connect_data, 0, sizeof(connect_data)); 

また、この変数は、アドレスがコールバック関数で使用されるため、 uv_tcp_connect()が呼び出された後も存在し続ける必要があることに注意してください。

2番目のパラメーターuv_tcp_t *ハンドルは、事前に作成する必要があるが、どのポートにもバインドされていないTCPソケットです。 TCPソケットの作成はuv_tcp_init()関数によって実行されます。これについては後で説明します。

3番目のパラメーターconst sockaddr * addrは、接続が要求されているサーバーのIPアドレスとポートです。 Libuvには、この構造にデータを取り込むのに役立つuv_ip4_addr()関数があります。

 sockaddr_in dest; uv_ip4_addr("234.123.34.18", 57, &dest); 

4番目のパラメーターuv_connect_cb cbは、カスタムコールバック関数です。 また、この機能では、ユーザーは接続が確立されたかどうかを判断し、何らかの形でこの事実に反応することができます。

私の場合、コールバック関数は次のようになります。

 void OnConnect(uv_connect_t* req, int status) { if (status==0) { //   ... ... ... } else { //    ... ... ... } } 

ソケットを作成する


TCPソケットはuv_tcp_t構造体によって記述されます 。 まず、この構造にメモリを割り当てる必要があります。

 uv_tcp_t* tcp_socket=malloc(sizeof(uv_tcp_t)); 

希望する場合は、割り当てられたメモリをゼロでクリアできますが、これはオプションの操作です。

 memset(tcp_socket, 0, sizeof(uv_tcp_t)); 

次に、ソケット自体を作成できます。

 uv_tcp_init(&loop, tcp_socket); 

here ループは、 uv_run()で提供される同じ苦痛のループです。

そして今、ゲーム作家にとって非常に重要なポイント:

 uv_tcp_nodelay(tcp_socket, true); 

この設定が何であるかを説明しようとします。 実際には、 TCPプロトコルはそれ自体を非常にスマートなプロトコルと見なし、送信されるデータの最小サイズはいずれにしてもパケットサイズと等しくなるため、小さな部分でデータを送信することは有益ではないことを知っています。 言い換えると、1バイトを送信すると、結果としてパケット全体が送信されます。この場合、1バイトの有用なデータのみが送信され、残りはゴミになります。 したがって、デフォルトでは、スマートTCPは、ユーザーが送信するデータを追加してすべてを一度に転送するまで200ミリ秒待機します。 この待機メカニズムは「 Nagle Algorithm 」と呼ばれ、ゲームにはまったく適していません。 したがって、この設定は、そのままで、 TCPプロトコルに指示します-「聞いて、親愛なる、賢くなくて、すぐにデータを送信しましょう」。 実際には、この設定はTCPプロトコルがネイルアルゴリズムを使用することを禁止します

これがサーバーの場合、メインソケットはすぐにポートにバインドする必要があります。

 sockaddr_in address; uv_ip4_addr("0.0.0.0",   , &address); uv_tcp_bind(tcp_socket, (const struct sockaddr*)&address, 0); 

この場合、「0.0.0.0」がアドレスとして示されます。これは、ソケットが1つだけでなく、コンピューター上に存在するすべてのネットワークアダプターにバインドされることを意味します。 サーバーポート番号は独立して選択する必要があります。ここでの主なことは、他のプログラムと競合しないようにするための一意性です。

次に、サーバーはソケットのポートリッスンを有効にする必要があります。

 uv_listen((uv_stream_t*)tcp_socket, 1024, OnAccept); 

もちろん、最初のパラメーターtcp_socketはソケット自体ですが、2番目のパラメーターはより具体的です。 これは、インラインで待機できる接続要求の最大数です。 非常に人気のあるサーバーがあり、プレイヤーがそのサーバーでプレイしようとしていると想像してください。 クライアントから多くの接続要求があります。 サーバーがそれらに応答する時間がない場合、サーバーはそれらをキューに入れます。 この場合、この数値1024はこのキューの最大サイズです。 キューに入れられていない人のために、サーバーは3つの単語で応答します。

3番目のOnAcceptパラメーターは、クライアントがuv_tcp_connect()関数を使用するクライアントからCONNECT接続要求を受信したときに呼び出されるコールバック関数です。

OnAccept()関数は次のようになります。

 void OnAccept(uv_stream_t *server, int status) { if (status<0) { //    return; } //    ,        uv_tcp_t* tcp_socket=malloc(sizeof(uv_tcp_t)); memset(tcp_socket, 0, sizeof(uv_tcp_t)); uv_tcp_init(&loop, tcp_socket); if (uv_accept(server, (uv_stream_t*)tcp_socket)==0) //     { //   uv_read_start((uv_stream_t*)tcp_socket, OnAllocBuffer, OnReadTCP); //      } else { //     uv_close((uv_handle_t*) tcp_socket, OnCloseSocket); //  ,      } } 

OnAccept()関数の内部をさらに詳しく調べてみましょう。

  1. 最初に、新しいソケットが作成されます。これは、接続を確立する要求の送信元であるクライアントに接続するために使用されます。 クライアントはuv_tcp_connect()を使用して接続を確立します。

  2. Uv_accept()が呼び出され、サーバーとクライアント間の接続が実行されます。 uv_accept()関数により、クライアントは、 uv_tcp_connect()で最後のパラメーターとして指定されたコールバック関数を呼び出します。 上記の例では、 OnConnect()関数でした。

  3. 接続が正常に確立された場合、サーバーは作成されたソケットがこの接続からデータを読み取ることを許可する必要があります。 uv_read_start()関数は、新しく作成されたtcp_socketソケットのデータの読み取りを有効にします。 「データの読み取り」と「ソケットのリッスン」は異なる操作であることに注意してください。 「データの読み取り」は、ファイルからのバイトの読み取りと同様に、文字通り「読み取り」であり、「ソケットをリッスン」は、接続を確立するためのCONNECT要求を待機しています。

    uv_read_start()関数は、2つの整数コールバック関数を使用します。

    -OnAllocBuffer()は、データを読み取る前に呼び出され、データを受信するためのメモリを指定するようユーザーに要求します。

    関数自体は次のように定義されます。

     void OnAllocBuffer(uv_handle_t* handle, size_t suggested_size, uv_buf_t* buf) 

    最初のパラメーターuv_handle_t * handleは、メモリを必要とするソケットへのポインターです。

    2番目のパラメーターsize_t suggest_sizeは、必要なバッファーサイズです。

    3番目のパラメーターuv_buf_t * bufは、ユーザーがバッファー情報(サイズとアドレス)をlibuvライブラリに返すための構造です。

     buf->len=; buf->base=; 

    つまり、バッファ自体はユーザーが作成し、ユーザーも削除する必要があります。 また、 libuvはこのバッファーへのデータのみを受け入れます。 同じバッファを複数のソケットに使用できます。

    私の場合、何らかの理由でlibuvは常に65536バイトのバッファーを要求しました。 私にとっては、これは少し奇妙ですが、このメモリを1回割り当てるので、問題はないようです。

    -OnReadTCP()OnAllocBuffer()の後に呼び出され、ソケットがバッファで受信したデータをユーザーに渡します。

    関数自体は次のように定義されます。

     void OnReadTCP(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) 

    最初のパラメーターuv_handle_t *ハンドルは、データを受信したソケットへのポインターです。

    2番目のssize_t nreadパラメーターは、受信したバイト数です。 nreadがゼロ以下の場合、これは接続が切断されたことを示しています。 つまり これはデータを読み取るのではなく、2番目の側がこの接続に関連するソケットを取り外したという事実、または「ケーブル破損」などのハードウェアの理由により発生する可能性のある切断に関する情報です。 この状況を監視し、それに応じて対応する必要があります。

    3番目のパラメーターはconst uv_buf_t * buf-読み取りデータのあるバッファーのアドレスを含みます。 これは、ユーザーがOnAllocBuffer()関数で指定したものと同じアドレスになります。

    データを読み取る最も重要な瞬間。 前述したように、 TCPは送信メッセージを部分的に配信できます。たとえば、送信者が「Hello」というフレーズを送信し、次に「Server」というフレーズを送信した場合、これら2つのメッセージを受信したときに情報がまったく同じように見える必要はありません配送形態で。 まず、両方のメッセージを1つにまとめると、受信側は「Hello、Server」というメッセージ全体を受信します。最初に「When」、次に「Vet、Ser」、「Ver」などのメッセージを受信します。 つまり 理論上のメッセージは、任意の数の部分に断片化できます。 したがって、ネットワークを介して送信されるすべてのメッセージには、常に、あるメッセージを他のソケットからのバイトストリームから分離できるヘッダーが必要です。 これはすべて、 TCPソケットからデータを読むという非常に不快な機能につながります 。 実際には、そのようなソケットにはそれぞれ次のメッセージの着信バイトを格納する独自のバッファーが必要です。メッセージが部分的にしか受信されない場合、完全に受信されるまで待機する必要があり、その場合にのみ何らかの方法で応答できるからです

  4. 接続を確立できなかった場合、その接続用のソケットを削除する必要があります。 これを行うには、 uv_close()関数を使用します。 しかし...この関数は、最後のパラメーターとしてOnCloseSocket()コールバック関数も受け入れることに注意してください。 また、この関数を呼び出した時点で、libuvはソケットをメモリから物理的に削除できるようになったことをユーザーに伝えます。

     void OnCloseSocket(uv_handle_t* handle) { free(handle); //     malloc(),      free() } 

メッセージを送信する


ソケットを介してメッセージを送信するには、 uv_write()関数を使用します。

 int uv_write(uv_write_t* req, uv_stream_t* handle, const uv_buf_t bufs[], unsigned int nbufs, uv_write_cb cb); 

最初のパラメーターuv_write_t * reqは、 libuvがデータを転送するために必要なある種の変数です。 関数パラメーターに存在する意味を掘り下げることは価値がありませんが、たとえば次のように作成する必要があります。

 uv_write_t write_data; 

現在、この変数はメッセージの連続送信に繰り返し使用できますが、複数のメッセージの送信に同時に使用することはできません。

2番目のパラメーターuv_stream_t * handleは、送信側ソケットです。

3番目のパラメーターconst uv_buf_t bufs []は、送信するメッセージの配列です。 uv_buf_t型の要素で構成されます。これらの要素にはlenフィールドとbaseフィールドがあり、それぞれSIZEADDRESSが含まれている必要があります。

4番目のパラメーターunsigned int nbufsは、メッセージ配列内の要素の数です。 私の場合、送信するメッセージは常に1つだけでした。

5番目のパラメーターuv_write_cb cbは、メッセージが送信されたときに呼び出されるコールバック関数です。 なぜ必要なのですか? 実際、ユーザーが送信するメッセージは、送信されるまでメモリに保存する必要があります。 つまり このコールバック関数がトリガーされると、送信されるメッセージを含むデータバッファーがlibuvで不要になることを意味します。 そして今、このバッファは再びユーザー制御下にあり、新しいデータで満たされ、新しいメッセージを送信できます。

私の場合、データバッファーと、あいまいだが必要なuv_write_t write_dataの両方を1つの構造に入れています。 したがって、同じ構造の一部としてペアで機能します。

libuv構造からより便利なデータ型への移行


メッセージを受け入れるuv_tcp_tのような多くのソケットがあると想像してください。 少し上で述べたように、データはフローの原則に従って読み取られ、メッセージは任意の部分に分割できるため、着信データを格納および分析するために各ソケットにバッファが追加で必要になります。 そして、 OnReadTCP()コールバック関数をもう一度見てみましょう。

 void OnReadTCP(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) 

また、 uv_stream_t *ストリームパラメーターを介して、データを受信したソケットが表示されることに注意してください。 しかし... ...ソケットを目的のデータバッファにどのように関連付けますか? 1000個のソケットがあり、誰もがそこに何かを受け入れるとしましょう。 ソケットごとに、受信したデータを独自のバッファーに入れる必要があります。 しかし、結局のところ、そのバッファーはソケットによって決定されるわけではありません-ソケット構造内には、使用可能な数千のバッファーのどれに対応するかの兆候のないナンセンスがあります。

したがって、ネットワーキングの原理は完全に異なるはずです。次に、この問題をどちらの側で解決できるかを説明しようとします。

とりあえず、 libuvの存在を忘れて、共通のサーバー構造を作成してみましょう。

GNetServerというサーバークラスがあるとします 。 このクラスのオブジェクトは常に単一のインスタンスに存在し、サーバー機能を完全に想定しています。 私の場合、サーバーには2つのメインアレイが必要です。


これら2つの配列の間には密接な関係が確立されている必要があります。 プレイヤーは自分が所属しているセッションを知っている必要があります(すでにセッションに参加している場合)。また、どのセッションもそのプレイヤーがコンポジションに含まれていることを知っている必要があります。

プレーヤーとセッションの要件は何ですか? 主な要件は、個人の一意の識別子による目的のプレーヤーまたはセッションへの迅速なアクセスです。 そして、この識別子を単純に配列内のプレーヤーまたはセッションのインデックスにするよりも高速な方法を考え出すことはおそらく不可能です。 そのため、すべてのプレーヤーにはIDがあります。これは、プレーヤーの合計配列内のIDと単純に同じです。 そして今、別のプレーヤーが識別子5、10、21、および115のプレーヤーにデータを送信する場合、サーバーは、識別子をインデックスとして使用するだけで、これらの受信者をすぐに識別できます。

次に、サーバーの観点から「プレーヤー」が何であるかを判断しましょう。 実際、「プレーヤー」は「ソケット」であり、いくつかの追加情報しかありません。 追加情報には次のデータが含まれます。


この情報はすべてGNetSocketクラスに保存されます。 ただし、 libuvからのデータはないことに注意してください。 これは、必要に応じてlibuvを別のものに置き換えることができるように行われます。

私の場合、 GNetSocketを継承するGNetSocketLibUVクラスがあります。 実際にはGNetSocketLibUVクラスのオブジェクトのみが作成される場合、なぜ中間GNetSocketクラスが必要なのですか? 事実、私の仕事は、可能な限り一般的なネットワーク構造からlibuvライブラリを分離することでした。 その結果、メインサーバー/クライアントファイルは7000行以上を占有し、 libuv固有のファイル 600行かかります。 また、 libuvを置き換える必要がある場合は、比較的簡単に行うことができます。 また、キークラスのオブジェクトがnewを介して直接作成されるのではなく、仮想関数を介して作成される場合にも原則を使用します。たとえば、これによりサーバーのソケットオブジェクトが作成されます。

 GNetSocket* GNetServerLibUV::NewSocket(GNet* net) { return new GNetSocketLibUV(net); } 

つまり 1つの場所で交換して、 新しいGNetSocketLibUV(net)を返す必要があります。 他のタイプのオブジェクトに対しては、プログラム内ではこのタイプのみが作成されます。

GNetSocketLibUVクラスは、基本クラスのすべての抽象関数をオーバーライドします。 次のようになります。

 class GNetSocketLibUV : public GNetSocket { public: void* sock; public: GNetSocketLibUV(GNet* net); virtual ~GNetSocketLibUV(); //  UDP  TCP- ,   listaen=true,     virtual bool Create(bool udp_tcp, int port, bool listen); //     TCP-    virtual bool SetConnectedSocketToReadMode(); //   virtual void Destroy(); //  IP-   TCP- // own_or_peer           virtual bool GetIP(CMagicString& addr, bool own_or_peer); //      (  TCP-) virtual bool Connect(NET_ADDRESS* addr); //     (  TCP-) virtual bool Accept(); virtual void SendTCP(NET_BUFFER_INDEX* buf); virtual void SendUDP(NET_BUFFER_INDEX* buf); virtual void ReceiveTCP(); virtual void ReceiveUPD(); }; 

1つの変数void * sockのみがGNetSocketLibUVクラスに追加れました。これは、libuvソケットへのほとんどのポインターになりますが、注意が必要です。私たちは、すぐにソケットへの能力が必要libuv対応するソケットタイプを決定GNetSocketを単にバッファを読むためにどの嘘、そしてプレイヤーのID。どうやってやるの?中間構造を追加しました:



 struct NET_SOCKET_PTR { GNetSocket* net_socket; }; struct TCP_SOCKET : public NET_SOCKET_PTR, public uv_tcp_t { }; struct UDP_SOCKET : public NET_SOCKET_PTR, public uv_udp_t { }; 

だから... ...今、私たちはのための新しい構造を持っているTCPソケットと呼ばTCP_SOCKETとのための新構造UDPソケットと呼ばれるUDP_SOCKETを。しかし...これらの構造は両方ともソケット構造の前に新しいフィールドを持ちます。これはGNetSocketクラスの親オブジェクトへのポインタです。

もう1つ重要な点です。プログラムはどこにも「ネイティブ」なlibuvソケットを作成するべきではなくTCP_SOCKETUDP_SOCKETなどのソケットのみを作成する必要があります。作成直後に、その一部として作成されGNetSocketオブジェクトのアドレスをnet_socketフィールドに記録する必要がありますTCP_SOCKETまたはUDP_SOCKET

実際には、ソケットの作成は次のようになります。

 //  UDP  TCP- ,   listen=true,     bool GNetSocketLibUV::Create(bool udp_tcp, int port, bool listen) { GNetSocket::Create(udp_tcp, port, listen); uv_loop_t* loop=GetLoop(net); if (udp_tcp) { sock=malloc(sizeof(TCP_SOCKET)); memset(sock, 0, sizeof(TCP_SOCKET)); ((TCP_SOCKET*)sock)->net_socket=this; ... ... ... } 

さて、ときに我々のボイド*靴下はアドレスですTCP_SOCKETまたはUDP_SOCKET、と私たちは常に構造の最初は常に根底にあるソケットへのポインタになることを知っているGNetSocket * Net_Socket、「迅速なインストール適合性」のタスクはほとんど解決しました。

必要なデータを取得するのに役立つ関数をいくつか追加します。sockTCP_SOCKETの

場合、次の関数にsockアドレス渡すとlibuv TCPソケットが簡単に抽出されます

 uv_tcp_t* GetPtrTCP(void* ptr) { return (uv_tcp_t*)(((char*)ptr)+sizeof(void*)); } 

sockUDP_SOCKETの場合、次の関数にsockアドレス渡すと、libuv UDPソケットが簡単に抽出されます

 uv_udp_t* GetPtrUDP(void* ptr) { return (uv_udp_t*)(((char*)ptr)+sizeof(void*)); } 

GetNetSocketPtr関数(ソケットのuv_tcp_tまたはuv_udp_tのアドレス)を使用すると、このソケットに対応するGNetSocketタイプのメインソケットのアドレスを取得できます

 GNetSocket* GetPtrSocket(void* ptr) { return *((GNetSocket**)ptr); } GNetSocket* GetNetSocketPtr(void* uv_socket) { return GetPtrSocket(((char*)uv_socket)-sizeof(void*)); } 

実際に使用する方法は?たとえば、TCPソケットを読み取りモードにする必要があります。

 //     TCP-    bool GNetSocketLibUV::SetConnectedSocketToReadMode() { if (udp_tcp) { uv_tcp_t* tcp=GetPtrTCP(sock); int r=uv_read_start((uv_stream_t*)tcp, OnAllocBuffer, OnReadTCP); return (r==0); } return false; } 

靴下GetPtrTCP(sock)を使用してuv_tcp_t * tcpに変わり、すでにuv_read_start()関数に渡すことができることに注意してくださいさて、私の場合、OnReadTCP()コールバック関数は次のようになります。



 void OnReadTCP(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) { GNetSocket* socket=GetNetSocketPtr(stream); if (nread>0) { NET_BUFFER* recv_buffer=socket->net->GetRecvBuffer(); assert(buf->base==(char*)recv_buffer->GetData()); recv_buffer->SetLength(nread); socket->ReceiveTCP(); } else { //  ,    socket->net->OnLostConnection(socket); } } 

最初の行:

 GNetSocketLibUV* socket=(GNetSocketLibUV*)GetNetSocketPtr(stream); 

GNetSocketオブジェクトのアドレスを取得します。このオブジェクトに対して、関数socket- > ReceiveTCP()が実行され、ソケットが受信したメッセージの受信を実現します。彼女はすでにこのデータを自分のソケットバッファーに入れ、メッセージが完全に受信されたことを確認してから、サーバーまたはクライアントに送信して処理します(サーバーとクライアント間のコードの大部分は一致しています)。

また、ソケットを削除する例を示します。

 //   void GNetSocketLibUV::Destroy() { if (sock) { if (udp_tcp) { uv_tcp_t* tcp=GetPtrTCP(sock); uv_close((uv_handle_t*)tcp, OnCloseSocket); ((TCP_SOCKET*)sock)->net_socket=NULL; } else { uv_udp_t* udp=GetPtrUDP(sock); int r=uv_read_stop((uv_stream_t*)udp); assert(r==0); uv_close((uv_handle_t*)udp, OnCloseSocket); ((UDP_SOCKET*)sock)->net_socket=NULL; } sock=NULL; } GNetSocket::Destroy(); } 

ここの行に注意してください。

 ((TCP_SOCKET*)sock)->net_socket=NULL; 

、つまりlibuvソケットはここでは削除されませんが、メインのGNetSocketオブジェクトこのソケットとの接続を持たなくなるため、一般的にそれ自体で削除されます。ただし、libuvがソケットでのすべての作業を完了すると、OnCloseSocket()コールバック関数が必然的に機能し、free()が実行されます。したがって、メモリリークは発生しません。

これで、libuvライブラリに関する会話を終了できると思います。私は、その作業の根底にある原則の本質を明確にしようとしましたが、これらの原則は、WinSockを含むほとんどのソリューションで実質的に同じであると思いますおそらく私の解釈では十分なコード例はありませんが、関数名でインターネット上で見つけるのはそれほど難しくありません。これらの関数で何をする必要があるのか​​、そしてそれらが互いにどのように相互作用するのかを説明しようとしました。私の目標はゲームを完成させることであり、ネットワークプログラミングの分野の専門家になることではなかったため、私の理解では不正確な点がある可能性があります。そのため、「必要かつ十分」に基づいてこのケースを見つけました。

GNetClientネットワーククライアントの構造


クライアントは異なる時点で完全に異なる動作をする必要があります。たとえば、最初にサーバーに接続してセッションを作成または参加する必要があり、ゲームが既に開始されている場合、マウスとキーボードから受信したデータを他のプレーヤーと交換する必要があります。

ステージでクライアントの作業の論理を破り、各ステージの動作を個別に記述することができます。

私の場合、次の段階があります

。GNetStadyEnumSession / NET_STADY_ENUM_SESSION-既存のゲームセッションのリストを取得する段階。
GNetStadyJoinSession / NET_STADY_JOIN_SESSION-ゲームセッションに接続し、独自のプレーヤーをセットアップする段階。
GNetStadyCreateSession / NET_STADY_CREATE_SESSION-セッションを作成し、それを設定し、自分のプレーヤーを設定できるステージ。
GNetStadyStartGame / NET_STADY_START_GAME-オンラインゲームを起動する段階。
GNetStadyGame / NET_STADY_GAME-ゲームのステージ。
GNetStadyMigrationHost / NET_STADY_MIGRATION_HOST-ホスト移行の段階。

ステージインデックスは次のように宣言されます。

 enum NET_STADY {NET_STADY_ENUM_SESSION, NET_STADY_JOIN_SESSION, NET_STADY_CREATE_SESSION, NET_STADY_START_GAME, NET_STADY_GAME, NET_STADY_MIGRATION_HOST, NET_STADY_NO=-1}; 

各ステージのオブジェクトは事前に作成され、GNetClient内のステージの配列に保存されます。

 // ,     class GNetClient : public GNet { protected: NET_STADY net_stady; //   int k_net_stady; //   GNetStady** m_net_stady; //    ... ... ... } GNetClient::GNetClient() : GNet() { net_stady=NET_STADY_NO; k_net_stady=6; m_net_stady=new GNetStady*[k_net_stady]; m_net_stady[NET_STADY_ENUM_SESSION]=new GNetStadyEnumSession(this); m_net_stady[NET_STADY_JOIN_SESSION]=new GNetStadyJoinSession(this); m_net_stady[NET_STADY_CREATE_SESSION]=new GNetStadyCreateSession(this); m_net_stady[NET_STADY_START_GAME]=new GNetStadyStartGame(this); m_net_stady[NET_STADY_GAME]=new GNetStadyGame(this); m_net_stady[NET_STADY_MIGRATION_HOST]=new GNetStadyMigrationHost(this); ... ... ... } 

すべてのステージの基本クラスはGNetStadyクラスであり、そこから他のすべてのステージが継承されます。

 // ,      class GNetStady { protected: GNetClient* owner; //    - GNetClient unsigned int stady_period; unsigned int stady_tick; public: GNetStady(GNetClient* owner); virtual ~GNetStady(){} virtual bool OnStart(NET_STADY previous, void* init); virtual void OnFinish(NET_STADY next){} virtual void OnUpdate(); //     ,    stady_period (    0) virtual void OnPeriod(){} //      virtual bool IsMessageCorrected(int message_type){return false;} }; 

ステージの機能について簡単に説明しましょう。


ネットワーククライアントステージ管理


クライアントがまだサーバーに接続していない場合を除き、一部のステージは常に最新です。

ネットワークでの作業を開始するには、次の機能を使用します。

 //    virtual bool GGame::StartNet(const char* ip); 

ip = NULLの場合、独自のローカルサーバーを起動します。それ以外の場合、ipはインターネットサーバーのIPアドレス(234.123.34.18:57など)である必要があります。

ネットワークでの作業を完了するには、次の機能があります。

 //    virtual void StopNet(); 

この関数は、ネットワークのすべての兆候、つまりローカルネットワークサーバーが存在する場合は停止し、GNetClientクライアントオブジェクトを破棄します

ネットワークの操作を開始するには、関数bool GGame :: StartNet(const char * ip)を呼び出す必要があります。 IPアドレスが指定されている場合、関数はネットワークを初期化し、指定されたアドレスにあるサーバーとの接続を確立しようとします。サーバーが応答して接続を許可した場合、関数はtrueを返し、そうでない場合はfalseを返します。サーバーへのすべてのさらなる呼び出しは確立されたTCP接続を通して実行されるでしょうip = NULLの場合、関数はサーバーへの接続を実行しません。代わりに、UDPソケットを作成します。ブロードキャスト要求を介してローカルネットワーク上のサーバーを検索するために使用します。StartNet()

関数の実行時に問題がなかった場合、ネットワークはNET_STADY_ENUM_SESSIONステージの実行を開始します。作成されたゲームセッションを検索します。これは次のように行われます...関数GNetStadyEnumSession :: OnPeriod()で0.5秒ごとに自動的に呼び出され、タイプMESSAGE_TYPE_ENUM_SESSIONのメッセージが送信されます。メッセージは、事前に確立された接続を介してインターネットサーバーにのみ送信されるか、ローカルネットワークへのブロードキャスト要求によって送信されます。いずれの場合も、インターネットサーバーまたはローカルネットワーク上のサーバーがこのメッセージを受信すると、同じ方法で応答します。つまり、サーバーで利用可能なすべてのセッションをリストするメッセージMESSAGE_TYPE_ENUM_SESSION_REPLYの形式で質問者に回答を送信します。各セッションはの簡単な説明があるセッションの作成時間IDホスト名刺に、プレイヤーの数とその特性セッションにおける症状のパスワードだけでなく、サーバーのIPアドレスをこの場合、クライアントはリクエストブロードキャストを送信し、接続を確立するためにクライアントが応答を送信したサーバーのアドレスを知る必要があるため、クライアントが接続できるようにすることはローカルネットワークにとって非常に重要です。

見つかったセッションに関する情報を受け取った後、クライアントは仮想関数virtual void GGame :: OnEnumSession(GSessionList * sessions、int count_general_session)を呼び出します。この関数は、実際にはユーザーにセッションのリストを表示することがタスクなのでGGameクラスでは定義されていません。GGameは普遍的な基本クラスであるため、使用できる特定のゲームについて何も知らないため、GGameはそのようなタスクを引き受けません。したがって、私の場合、この関数はクラスで再定義されていますGGameOnimodLandと受信したセッションを作成時間で並べ替えGListBoxコンポーネントに行を追加してユーザーに表示するのは彼女です

ユーザーがセッションを選択して「参加をクリックすると、次のようになります。クライアントは単純にNET_STADY_JOIN_SESSIONステージを開始します。これはGNetStadyJoinSession :: OnStart()関数でセッションへの接続を試行します。この試みはさまざまな理由で失敗する場合があります。たとえば、ゲームが開始された、別のプレーヤーが接続された、セッションにスペースがなくなったなどです。いずれの場合も、接続の許可はホストによって発行されます(サーバーではありません)。

これは実際にはどのように起こりますか?

ローカルネットワークの場合、最初にサーバーに接続する必要があります。これを行うには、ブール関数GNetClient :: ConnectToServer(const char * ip)を使用します。trueの場合はtrueを返します。サーバーのIPアドレスは、セッション情報から取得されます。接続を確立する前に、UDPソケットは最初に強制終了されます。これは、不要になり、代わりにTCPソケットが作成されるためです。これにより、接続が確立されます。クライアントはCONNECTを使用してサーバーにアクセスし、サーバーは要求を受け入れてACCEPTを介して応答する必要があります。接続が確立されたら、「ハンドシェイク」を交換する必要があります

これは何のためですか?実際には、別のプログラムのアクセスから誰でもサーバーに接続できます。 「ハンドシェイク」は、サーバーから見てクライアントを識別します。「ハンドシェイク」が成功すると、サーバーがクライアントにメッセージの送信を許可します。 「ハンドシェイク」自体は、メッセージMESSAGE_TYPE_HELLOを介して実行されます。メッセージMESSAGE_TYPE_HELLOには、クライアントバージョンライセンスキーなどのサービス情報が追加されます。サーバーはこの情報を確認し、MESSAGE_TYPE_HELLO_REPLYメッセージをクライアント返します。メッセージには一意のクライアントIDが含まれています。クライアントは、これを介してサーバーおよび他のプレーヤーとのすべてのさらなる通信を実行しますID。サーバーがID = 0を返した場合、これはクライアントがサーバーに受け入れられなかったことを意味します。この場合、サーバーはクライアントに障害の理由を説明するテキストを追加で送信します。このテキストは、たとえばGMessageBoxの形式でクライアント側に表示できます。インターネットサーバーへの接続も同様の方法で実行されることに注意してください。

クライアントがサーバーに接続されている場合、セッションへの接続要求をすぐにホストに送信する必要があります。ただし、ホストはセッションをパスワードで保護できるため、「自分のだけが接続できます。セッションに関する情報から、そのようなパスワードの事実が判断されます。この場合、ユーザーは最初にパスワードの入力を求められます。次に、関数bool GNetClient :: ConnectSession(NET_JOIN_DATA * join)。これは、タイプMESSAGE_TYPE_CONNECTINGのメッセージをホストに送信します。メッセージはホストIDに送信され、セッションIDから再び取得されます。まず、サーバーがメッセージを受信します。サーバーは受信者のリストを分析し、メッセージをホストに渡します。

メッセージを受信した後、ホストはセッションへのプレーヤーの受け入れが可能かどうかを確認し、パスワード(存在する場合)を確認して決定します。答えが「はい」の場合、ホストは最初に仮想関数virtual int GGame :: GetIndexOfConnectedPlayer()を使用して新しいプレーヤーのゲームスロットを選択し、セッションにプレーヤーを含めます。さらに、ホストには機能がありますvirtual void GGame :: OnNewPlayer(int index)。これにより、新しいメンバーの出現により何らかのアクションを実行できます。次に、ホストはメッセージMESSAGE_TYPE_CONNECT_REPLYの形式でクライアントに応答を送信します。肯定的な回答の場合、セッションでクライアントに割り当てられたスロットインデックス、セッション内の他のプレーヤーのステータスに関するすべての情報、およびいくつかの追加情報を送信します。メッセージMESSAGE_TYPE_CONNECT_REPLYを受信すると、クライアントはスロットを記憶し、サーバーから受信したデータでセッション変数を初期化します。

これで、新しいプレーヤーはセッションの特性を調整し、チャットメッセージを交換できます。ホストがゲームを開始できるように、セッションの各参加者は「準備ができています」ボタンをクリックする必要があります「これがないと、ホストの[スタート ]ボタンは無効になります。

ゲームセッション情報


セッション情報は、特定のゲームに依存しないように整理する必要があります。たとえば、突然「黄金の子供時代を覚えて」と言い、ゲームをやり直したい場合、シェルの構造を変更する必要はありません。そのため、セッションの説明のフィールドが任意であり、特定のゲームのみが分析に関与し、サーバーまたはクライアントが関与していないことを確認する必要があります。ただし、一部のフィールドは明確に定義する必要があります。これは、サーバーがセッションについて名前を知る必要があるためです。また、明らかな「の数や状態などのセッションに含ま選手、に関するいくつかの情報を解釈する必要が誰である」(コンピュータ空きスロットクローズドスロットを

この情報はすべてNET_SESSION_INFO構造に入れますこの構造の一部として、プレイヤーNET_PLAYER ** m_playerの配列があります。およびその番号int k_player;

しかし、最も重要なのは、任意の情報を格納できるフィールドがあります:

 int length_info; //  .  char* info; //  .  

同じフィールドがNET_PLAYER構造にあり、任意のプレーヤーの任意の情報を保存することもできます。

次に、次の関数と演算子NET_SESSION_INFO構造に追加します。

 virtual void Serialize(CMagicStream& stream); // /    NET_SESSION_INFO& operator=(const NET_SESSION_INFO& si); //   bool operator==(const NET_SESSION_INFO& si); //     bool operator!=(const NET_SESSION_INFO& si) //     

なぜこれがすべて必要なのですか?

重要なことを1つ説明しようと思います。私の意見では、オペレーターの使用はかなり危険なビジネスです。問題は、開発中に頻繁に新しい変数が構造に追加されることです。そして多くの場合、コンストラクタでそれらを初期化することを忘れることさえできます。そして、これらの変数は、比較演算子と平等に追加する必要があるという事実を忘れて-それは一回以上私に起こった、そして彼女と私は時々非常に怒っているの後に「tがオフに頭upuyu「物忘れのために。だから...結果として、私は次の決定に来ました。通常、重要な構造にはシリアル化が含まれます。より単純には、ストリームからデータを読み取り、ストリームに書き込むことができます。ほとんどの場合、ストリームはバイトが書き込まれる通常のファイルですが、メモリ領域もストリームにできる場合ははるかに優れています。私の場合、私はクラス書いCMagicStreamをして生成されたクラスからCMagicStreamFileCMagicStreamMemoryを。したがって、関数virtual void Serialize(CMagicStream&stream);実際にどのクラスオブジェクトストリームであるかに応じて、ファイルとRAMを操作する方法を知っています

ところで、もう1種類のクラスがありますCMagicStreamVirtualFileは、私の「シェル」の一部であり、仮想ディスクで動作するように設計されています。仮想ディスクは、独自のファイルシステムを含むいくつかのファイルです。仮想ディスクを使用して、その内部にゲームリソースを配置しました。仮想ディスクは、CMagicString GPlatform :: OpenVirtualDrive(const char * path)を介して、仮想ディスクファイルのパスを指定することで開くことができます。その結果、タイプ0のパスが返されます。このパスはシェルで使用され、仮想ディスク内のファイルを操作できます。ここで重要なことは、ファイルシステムで機能する「シェル」関数がこの方法を理解し、必要に応じて要求をファイルにリダイレクトすることです。たとえば、CMagicStream * GPlatform :: OpenStream(const char * file、int mode)関数このような普遍的な原則で動作し、ファイルが対応するパスを指している場合、仮想ディスクに正しくアクセスします。同じことが「現在のフォルダーの状況にも当てはまります。誰も仮想ディスク上に現在のフォルダーを作成する必要はありません

だから...ポイントに戻ります。私は、物忘れのせいでオペレーターが私にとってどのように危険になるかについて話しました。リスクを最小限に抑えるため、これを行います。バイナリ形式で構造のシリアル化関数を作成し、この関数にすべての演算子を渡します。つまりたとえば、割り当て演算子を記述する必要がある場合、構造フィールドを1つずつコピーする代わりに、書き込み用にRAMにCMagicStreamMemory型のストリームを作成しコピーした構造オブジェクトSerialize()を実行します。次に、読み取り用と同じストリームを作成します。結果の構造オブジェクトは、同じメモリ位置からSerialize()も実行します。そのような保存+負荷が判明しますRAMを介して。比較演算子を使用しても同じことができます。比較するオブジェクトのデータを2つの異なるストリームに書き込み、それらをバイト単位で比較し始めます。もちろん、この方法は、オペレーターがそれぞれのフィールドを計算する場合よりも遅くなります。ただし、ボトルネックの場合は、常に従来のバージョンを使用できます。シリアル化の利点は、通常の保存 / 読み込みデータにも使用されることです。そして、少なくとも私の場合、データの破損や保存されていないフィールドの方がはるかに印象的であるため、忘れっぽさがすぐに現れます。

さらに、シリアル化は、クリップボードを介してデータをコピーするのに最適です。そして、ネットワークの場合...ネットワークの相互作用は、「タイプ」、「サイズ」、「任意のデータのフィールドであるメッセージの送信に限定されることに注意してください。また、バイナリシリアル化を使用すると、あらゆるタイプのデータをネットワーク経由で送信可能なバイトストリームに変換し、受信側で元のデータに戻すことができます。

これで、ようやくネットワークセッションの設定が完了しました。ネットワーククライアントは、ゲームについて何も知らないようにし、プレーヤーの設定のほとんどについては知らないようにします。たとえば、「準備ができています」というフラグ「それは確かに必要はありません- 。彼はそれゲーム必要があり、ネットワーククライアントは、ネットワークを介して送信メッセージにあります

。私はそのようにしたGGameは、クラスで定義されなければならない4つの空virutalnye機能を発表しましたGGameOnimodLandを

 virtual bool GGame::GetSessionInfoStruct(NET_SESSION_INFO* si); //    NET_SESSION_INFO* si   . virtual void GGame::SetSessionInfoStruct(NET_SESSION_INFO* si); //      NET_SESSION_INFO* si  . virtual bool GGame::GetSessionPlayerStruct(int index, NET_PLAYER* np); //     NET_PLAYER* np      .  index   . virtual void GGame::SetSessionPlayerStruct(int index, NET_PLAYER* np); //       NET_PLAYER* np    . 

実際には、これらの機能は、ネットワーククライアントからゲームへ、またはその逆にデータを普遍的に転送します。同時に、GetSessionInfoStruct / SetSessionInfoStruct関数は、プレーヤーに関するデータを含むセッション全体のデータを処理します。また、GetSessionPlayerStruct / SetSessionPlayerStruct関数は、特定のプレーヤーのデータを処理します。NET_SESSION_INFOにはNET_PLAYERオブジェクトの配列が含まれているため、当然、プレーヤーの関数はセッション関数によって使用されます

このアプローチの後、ネットワーククライアントのゲーム自体は「ブラックボックス」に変わり、そこから「何か」が生まれ、「何か」が受信されます。そして今、プレーヤーを設定するプロセスを簡素化するために必要な重要なポイント。プレーヤーには多くの設定があり、変更できることを想像してください。たとえば、チームの色を変更したり、レースを選択したりできます。しかし、将来何が考えられるかは決してわからないので、後でプログラム構造を修正する必要はありません。NET_STADY_JOIN_SESSIONの

段階で、タイマーでvoid GNetStadyJoinSession :: OnUpdate()関数が呼び出され、普遍性の問題を解決する1組の行を実行します。GNetStadyJoinSession

ステージには変数がありますNET_SESSION_INFO * copy_session;最後のメジャーのセッション状態のコピーを保存します。それにもかかわらず、コード全体のかなりの部分を提供します。

 void GNetStadyJoinSession::OnUpdate() { GNetStady::OnUpdate(); //       NET_PLAYER* current_player=current_session->m_player[index_player]; NET_PLAYER* copy_player=copy_session->m_player[index_player]; owner->game->GetSessionPlayerStruct(index_player, current_player); if (*copy_player!=*current_player) { //        *copy_player=*current_player; if (current_player->type==PLAYER_MAN) { GMemWriter* wr1=owner->wr1; wr1->Start(); (*wr1)<<index_player; current_player->Serialize(*wr1); MEM_DATA buf; wr1->Finish(buf); //    ,    int k_receiver=owner->RefreshReceiverList(); NET_BUFFER_INDEX* result=owner->PrepareMessageForPlayers(MESSAGE_TYPE_PLAYER_INFO, buf.length, buf.data, k_receiver, owner->m_receiver); owner->GetMainSocket()->SendMessage(result); } } } 

実際には、次のことが発生します。変数index_playerは、セッション内のプレーヤーに属するスロット番号です。

String owner-> game-> GetSessionPlayerStruct(index_player、current_player);ゲームから同じプレーヤーの現在の設定を

取得 し、クライアントが記憶している設定と単純に比較します:if(* copy_player!= * current_player)
そして不一致がある場合、コピーが最初に一致します* copy_player = * current_player;そして、タイプMESSAGE_TYPE_PLAYER_INFOのメッセージをセッションのすべての参加者に送信します。この参加者では、プレーヤーの新しい設定が送信されます。

このアプローチの大きな利点は何ですか?事実は、ゲーム自体が他のプレイヤーに送信される設定に従うべきではないということです。GNetStadyJoinSession :: OnUpdate()はすぐにこの変更を認識し、セッションのすべての参加者に新しいデータを自動的に送信するため、構成で少なくとも1バイトを変更する価値があります同時に、GNetStadyJoinSession :: OnUpdate()は、構成可能な実際のデータについて何も知りません。これは、比較演算子がシリアライザーを介して機能し、長さが等しい場合にバイトストリームとストリームの長さが比較されるためです。

記事の例では、プレーヤーの構成の構造は次のようになります。

 struct PlayerCfg { int type; CMagicString name; unsigned int player_id; unsigned int color; bool ready; void Serialize(CMagicStream& stream) { if (stream.IsStoring()) { stream<<type; stream<<name; stream<<player_id; stream<<color; stream<<ready; } else { stream>>type; stream>>name; stream>>player_id; stream>>color; stream>>ready; } } }; 

しかし、ゲームではフィールドが完全に異なり、さらに多くのフィールドがあります。ただし、このアプローチは汎用性の点で優れています。

セッション作成


ただし、セッションに参加する前に、誰かがこのセッションを作成する必要があります。これにはNET_STADY_CREATE_SESSIONステージがあります。このステージのクラスは、NET_STADY_JOIN_SESSIONステージから継承されます。

 class GNetStadyCreateSession: public GNetStadyJoinSession 

これは、セッションの作成またはセッションへの参加のいずれかを実行するOnStart()関数を除き、これらのステージが非常に類似しており、大幅に異なるためです。NET_STADY_CREATE_SESSIONステージでは、構成変更のチェックも機能しますが、セッション内の各プレーヤーのチェックもあります。これは、ホストがセッション全体を制御し、たとえば他のプレーヤーを削除できるためです。

ところで、ネットワーククライアントは、プレーヤーの構成データの分析にまだ少し関与しています。メッセージMESSAGE_TYPE_PLAYER_INFO、クライアントが次のように分析する構成を担当します。メッセージを受け取った時点で、彼は新しい構成が来たプレイヤーが生きている人であったかどうかを覚えています(タイプ= PLAYER_MAN)。メッセージを受信すると、現在の構成が新しい構成に置き換えられます。ただし、クライアントはtypeフィールドPLAYER_MANであるかどうかのみをチェックします。また、フィールドが突然PLAYER_OPENED変更された場合、これは、たとえば、ホストがセッションからプレーヤーを削除し、スロットが開いていることを意味する場合があります。クライアントはこの状況の処理に関与しており、その結果としてbool GNetClient :: LostPlayer(unsigned int player_id)が呼び出されます。つまり、プレーヤーとの通信が失われます。さらに、これらはすべて、オプションのいずれかの形式でゲームに提供されます。

 //    (    ) virtual void GGame::OnCancelSession(); //        virtual void GGame::OnDeletingFromSession(); 

bool関数GNetStadyCreateSession :: OnStart(以前のNET_STADY、void * init)は、プレーヤーが[ゲームの作成]ボタンをクリックすると呼び出されます。これがローカルネットワーク上のゲームである場合、まず独自のサーバーを起動して参加する必要があります。

 //    bool GNetClient::CreateSession() { bool is=false; if (!internet) { //    if (StartLocalServer()) { is=ConnectToServer("127.0.0.1"); } else is=false; } else { is=true; } return is; } 

ConnectToServer 関数(「127.0.0.1」)では、UDPソケットが破棄され、T CPソケットが作成されます。次に、サーバーとの接続が確立されます。サーバーは、StartLocalServer()関数によって同じコンピューターで起動されます。サーバーのIPアドレスは「127.0.0.1」で、「同じコンピューター」を意味します。

次に、ホストは仮想void GGame :: OnCreateSession(int index_player)関数を呼び出します。この場合、ホストのスロットのみが常に0に設定されます。

次に、void関数GNetStadyCreateSession :: OnPeriod()を使用するホストセッション状態についてサーバーに定期的に通知し始めます。この関数は、0.5秒ごとに自動的に呼び出されます。タイプMESSAGE_TYPE_SESSION_INFOのメッセージをサーバーに送信します。このメッセージは常に送信されるわけではなく、セッション設定が変更された場合にのみ送信されます。ここでは、プレーヤー構成の変更と同じ原則が使用されます。

メッセージMESSAGE_TYPE_SESSION_INFOを受信すると、サーバーはまず、そのようなセッションがすでにセッションのリストにあるかどうかを確認し、ない場合は新しいセッションを追加します。メッセージの送信者は、最初の参加者およびホストとして新しいセッションに追加されます。
さらに、サーバーは、クライアントMESSAGE_TYPE_ENUM_SESSIONからの要求に応じて、既存のセッションに関する情報を送信します

ステージNET_STADY_CREATE_SESSIONは、プレーヤーが「開始ボタン押してゲームの起動を開始するまで続きます。実際には、現時点では、NET_STADY_START_GAMEステージはnet-> SetStady(NET_STADY_START_GAME、NULL)への呼び出しを介して設定されます。
メッセージMESSAGE_TYPE_START_GAMEは
void関数GNetStadyCreateSession :: OnFinish(NET_STADY next)から自動的に送信されますこれはNET_STADY_CREATE_SESSIONが完了すると呼び出されます。ここで、ホストはサーバーに、セッションが参加のために閉じられたことを通知し、他のプレーヤーに通知する必要はありません。

メッセージMESSAGE_TYPE_START_GAMEは、サーバーによってセッションのすべての参加者に送信されます。それを受信すると、それらすべてもNET_STADY_START_GAMEステージに切り替えます。

さらに、ゲームの起動段階の主な作業は、関数:
void GNetStadyStartGame :: OnPeriod()で実行されます。これは、関数を介して5から1へのカウントダウンを実行します仮想ボイドGGame :: OnStartNetCounter(int counter);実際には、5、4、3、2、1という数字がチャットエリアに出力されます。次に、仮想ボイドGGame :: OnStartNetGame()が呼び出され、ゲームセッションの起動準備プロセスが開始されます。この時点で、ゲームカードが読み込まれ、プレーヤーがその上に配置されます。このプロセス全体が各コンピューターで独立して実行されることに注意してください。すべてのデータが初期化されると、仮想ブールGGame :: IsNetGameLoaded()関数trueを返すはずです。この関数はGNetStadyStartGame :: OnPeriod()から継続的に呼び出されfalseが返される間、ネットワーククライアントはゲームの初期化が継続すると想定します。帰ってすぐにtrueの場合、メッセージMESSAGE_TYPE_PLAYER_STARTEDがすぐに他のすべてのプレーヤーに送信されますその時点で、ネットワーククライアントは、セッションのすべての参加者からメッセージMESSAGE_TYPE_PLAYER_STARTEDを既に受信していることを検出するとNET_STADY_GAMEステージに進み、これはゲームの開始を意味します。bool

関数GNetStadyGame :: OnStart(NET_STADY以前、void * init)はすぐに仮想void GGame :: OnLaunchNetGame()を呼び出し、これが起動です。それだけです その後、ゲームが始まります。

ネットワークゲーム


ステージNET_STADY_GAMEは、void関数GNetStadyGame :: OnUpdate()を通じてゲームプレイ全体を制御します。実際には、このステージでは、プレーヤーが一定期間マウスとキーボードを使用して入力したチームを送信します。また、この段階では、他のプレイヤーからのまったく同じデータが期待されます。

ユーザーコマンドは、メッセージMESSAGE_TYPE_PLAYER_GAMEを介して送信されます。 GNetStadyGameステージには次のフィールドがあります。

 int k_player; PLAYER_MESSAGE* m_player; 

他のプレイヤーからチームを受け取るために使用されます。PLAYER_MESSAGE構造には、NET_BUFFER next_messageメッセージを受信するためのバッファがあります。ただし、その目的は、一見すると思われるかもしれません。実際には、ネットワークタクト番号という概念があります。これは、ゲームが終了するまで0から無限に増加するクロックをカウントするような変数です。ネットワーククロックは、ネットワーククライアントが現在のネットワーククロックのすべてのプレーヤーからコマンドを受信した場合にのみ増加します。それ以外の場合、待機があり、ゲームがフリーズします。ただし、通常は通信の品質によりメッセージを非常に安定して配信できるため、これらの遅延はプレーヤーに気付かれずに発生します。

それでもクライアントが長時間待機し始める場合、仮想ボイド関数GGame :: OnWaitingPlayers(unsigned int dtime、int k_player_id、unsigned int * m_player_id)介してこのゲームを報告し、ゲームのタスクはこのプレイヤーのリストを画面に表示することです。待機時間は制限されており、クライアントは待機時間が無限にならないようにします。制限時間に達すると、クライアントは問題のあるプレーヤーを切断し始め、敗者であると宣言します。これにより、すぐにゲームが続行されるか、勝利によりゲームが完了します。

クライアントが現在のメジャーに対する別のプレーヤーからコマンドを受け取ると、それらは仮想ボイド関数GGame :: SetPlayerNetMessage(unsigned int sender、MEM_DATA&message)を使用してすぐにゲームに転送されます。ただし、受信したメッセージがすぐにゲームに転送される場合、なぜもう1つのNET_BUFFER next_messageバッファーが必要なのあまり明確ではないでしょうか?しかし、なぜ。

すでに述べたように、ネットワークの相互作用はマルチスレッドと非常によく似ており、スレッドの同期により一部のアクションが無期限に遅延する可能性があります。ネットワークゲームでは、あるコンピューターが1 ネットワーククロックサイクルだけ別のコンピューターを追い越し始めると、状況が簡単に発生します。この場合、先の次のサイクルのためのメッセージとして表示されるコンピュータメッセージを追い越しから来た私たちのコンピュータはまだ達していません。そして、コンピュータは次のことを行う必要があります...このメッセージを独自のバッファに保存するだけですNET_BUFFER next_message、現在のところ一時的にこのサブジェクトに対してアクションを実行しなくなります次のネットワーククロックのためにそのようなプレーヤーからメッセージが既に受信されていることがわかります。いつになる次のサイクルのネットワークは、まず最初に私たちのコンピュータvozmotその後、これらの独自のバッファからのコマンドとは、を介してゲームに渡す仮想空GGame :: SetPlayerNetMessage(unsigned int型の送信者、MEM_DATA&メッセージ)。これは、同様のゲームでネットワークの相互作用を構築するために理解することが望ましいという非常に重要なポイントです。

また、「他のプレイヤーを待つ」プロセスが機能するため、2メジャーの追い越しができなくなることも理解する必要があります。"したがって、最大の前進は1 ネットワーククロックのみです

ただし、コマンドを受信するには、最初にそれらを送信する必要があります。ネットワーククライアントは、ゲームで使用されるコマンドの詳細について何も知る必要がないため、仮想MEM_DATA関数を呼び出すだけです:: GetPlayerNetMessage GGame()などのチームに対する緩衝準備するために彼を返し、MEM_DATAが得られたバッファは、他のすべてのプレイヤー自身のプレイヤーに同時に送信されます。

 MEM_DATA message=owner->game->GetPlayerNetMessage(); GNetSocket* socket=owner->GetMainSocket(); owner->game->SetPlayerNetMessage(socket->player_id, message); //     ,       int k_receiver=owner->RefreshReceiverList(); owner->m_receiver[k_receiver]=takt; //      ,     NET_BUFFER_INDEX* result=owner->PrepareMessageForPlayers(MESSAGE_TYPE_PLAYER_GAME, message.length, message.data, k_receiver, owner->m_receiver, 1); socket->SendMessage(result); //        

ゲームはネットワーククロックを制御し、仮想int関数GGame :: GetNetTakt()を介してクライアントに返します。ただし、クライアントはネットワークタクトの完了を制御します。これは、すべてのプレーヤーからすべてのチームが受信される瞬間です。クライアントは、仮想ゲームGGame :: OnNextNetTakt()を呼び出して、このゲームをすぐに報告します。私の場合、この関数はネットワークの同期解除をチェックし、すべてが正常な場合にtrueを返しますfalseが返される場合、ネットワーククライアントは非同期の修正プロセスを自動的に開始します。ホストはすべてのデータをファイルに書き込み、このファイルを他のすべてのプレーヤーに転送します。他のプレーヤーはこのファイルを読み取り、このデータでゲームを続行します。実際には、ホストは保存を行いますそして、残りのプレーヤーはLoadです。1つのネットワーククロックサイクルで生成された乱数合計をカウントすることにより、ネットワークの同期を制御します乱数は連続的に生成され、すべてのコンピューターで同じである必要があります。そうでない場合、これはネットワークが同期していないことを示しています。もし仮想BOOL GGame :: OnNextNetTakt()を返すtrueに、クライアント自身が変数にこの事実を指摘しon_next_net_takt -彼のためにそれは電源サイクルが完了したことを意味します。ゲームは、メインループでクライアント関数bool GNetClient :: IsNextNetTakt(){return on_next_net_takt;}を定期的に呼び出す必要があります

trueが返されると、ゲームはネットワーククロックを1 増やし、各プレーヤーの最後のネットワーククロックネットワーク経由で受信したすべてのコマンドを実行します。その後、コマンド配列がクリアされ、すべてが新しい方法で開始されますが、ネットワーククロックの値が増加します

プレーヤーがマウスとキーボードから入力したコマンドは、すぐにはネットワークに到達しません。実際、現在のネットワーククロックサイクルでは最後のネットワーククロックサイクルで収集されコマンドがネットワークに送信され、この時点でマウスとキーボードからの新しいコマンドが収集されます。つまり1ネットワーククロックの反応に遅れがありますこの遅延はネットワーク遅延と呼ばれますただし、ネットワーククロックをゲームクロックと同じにすることには意味がありませんたとえば、ゲームが1秒間に60回更新される場合、1つのネットワーククロックに対応するゲームの試合10 回行うことは完全に可能です。ユーザーが1/6秒の反応遅延に非常に悩まされることはほとんどありません。

トラフィック暗号化


私は、データ保護のトピックに強くないことを認めなければなりません。そして、結局、私はそれに注意を向けたいだけです。開発者がこれを行うことを期待しているため、ゲームからサーバーに接続する必要はありません。実際には、任意のIPアドレスとポートとの接続を確立する機能を持つ任意のプログラムに接続できます。その後、サーバーへの送信を開始できます。サーバーは、少なくともメッセージが正しくないことを確認する必要があります。そうでない場合、サーバーは単に「せん妄」の一部を受け取り、その中に巻き込まれます。

また、両側のメッセージは通常暗号化されます。

このトピックについては特に説明しませんが、自宅でのトラフィックの最小暗号化も使用したとのみ言います。防御を破ることは難しいとは思わない。なぜなら、この段階では、それを行う意欲も強さもないからだ。当分の間、私のプロジェクトの小さな名声に守られ、そこにそれが見られることを願っています...

Windowsに非常に長い間存在する1つのエラーについて


記事の最初の部分では、RTSのネットワークゲームを一般的なアイデアとしてのみ説明しましたしかし、そこで私はRTSのネットワークあふれている最大の問題を指摘しました-すべてのコンピューターで完全に同一の計算が必要です。コンピューターが少なくとも少し間違え始めたら、数分ですべてがコンピューターによって大きく異なります。そして、プレーヤーが他のコンピューターの「銃撃戦で殺された」コンピューターのユニットをコントロールしようとすると、ゲームは単純に混乱に陥ります。このようなエラーは、私が見たものの中で最もひどいものだと思います。なぜなら、そのようなバグを論理で見つけることは実際には不可能だからです。通常、このようなエラーの原因は、「ネットワークゲームの再起動時に変数を再初期化するのを忘れた」などの些細な些細なことです。その結果、この変数はネットワークの同期を破ることが保証されており、ゲーム自体が最終的に崩壊するずっと前からです。

このトピックに関する私の推論に興味がある人は、記事の最初の部分を参照してください。私の意見では、昔からWindowsに存在していた非常に汚いグリッチについてお話したいと思います。浮動小数点の計算でエラーが発生することが保証されており、これによりネットワークが強制終了されます。

この問題はおそらくWindows 98で2003-2004年に発見され、そこからWindows XPに正常に移行し、最近Windows 8でも何も変わっていないことがわかりました

エラーの主なポイントは、対応するWindows関数FPU制御ワードを変更する(そして戻らない)ことです。そして、もちろん、そのような動作についてはドキュメントのどこにも言及されていません。

ここに、Windows XPの問題の存在を証明する古いコードがありますのWindows 8、私はそれを試していないが、上のWindows 8私のよく確立されたネットワークゲームは、明白な理由もなく突然醜い作業を開始するとき、私はまた、「状況に飛びました」。この問題を補うコードを誤って削除したことが判明しました。

したがって、関数の例:

 int Error1() { double step=66.666664123535156; double start_position_interpolation=0; double position_interpolation=199.99998474121094; double vdiscret=(position_interpolation-start_position_interpolation)/step; int discret=(int)vdiscret; return discret; } 

もちろん、数字は奇妙ですが、エラーが表示されます。Error1()

関数を呼び出してデバッガーで処理すると、結果として数値2がdiscret変数になります

 int result=Error1(); // result=2 ok=direct_3d->CreateDevice( D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hwnd, D3DCREATE_HARDWARE_VERTEXPROCESSING, &d3d9pp, device_3d ); result=Error1(); //    result=3 

つまり関数間呼び出す場合ERROR1()ウェッジ機能のDirectX作成-aデバイスのDirectXを、第2の機能呼び出しであろう予期しない結果discret = 3。これは、vdiscret初めて2.99999 .......になり、2回目にはすでに3になるという事実によるものです。実際には違いはわずかですが、ゲームdouble型の変数を使用しているため、ネットワークゲーム全体を殺すのに十分です。さらに、理由を知らずに、そこに何かを修正する方法はありません。形式自体はコード自体が正しいため、どこかで何らかのプロセッサ状態フラグが目的の値に戻らないためです。Windows 98

この問題は、Windows XPよりもさらに激しく現れましたそこで、この不具合は、WinAPIを使用して、利用可能なモニター解像度を一覧表示しようとしたときに発生しましたDirectXがなくてもWindows 8、私はすでに何をするかを事前に知っていたことから、この質問を調査していません。

私はこの惨劇を「治す」2つの解決策を知っています。別のストリームを作成したら、画面解像度を切り替えて、エラーとともにストリームを強制終了しました。この場合、この問題はメインスレッドに影響しませんでした。

2番目の方法は簡単です。

 unsigned int status=_controlfp(0,0); //    // ... // ... // ... _controlfp(status,_MCW_DN | _MCW_IC | _MCW_RC | _MCW_PC); 

これにより、画面の解像度を切り替えた後に表示されるグリッチがなくなり、数学は正しく動作し続けます。

ゲームまたはリリースの現在の状態


ゲームのリリース準備がすでに整っていると判断しました。はい、バランスを調整するか、いくつかの小さな間違いを修正しなければならない可能性がありますが、実際にはゲームをリリースする時です。いずれにせよ、さらなる改善に取り組むために、私はポイントが通常のプレーヤーがいる場合にのみ見ます。

最近、私は、comorgnetなどのよく知られたドメインに加えて、世界に陸地ドメインがあることを知って驚いた。私の英語のゲームはOnimod landと呼ばれているので、すぐにゲームのonimod.landドメインを取得しました。そのため、ゲームは以前のように独自の個人サイトonimod.landを持ちます。

Steamでのリリースの前に、それは少し後で来ると思いますが、今のところ、私は自分のサイトを通してゲームをリリースします。私のプロジェクトを財政的に支援したい人は、ゲームのあるサイトでこれを行うことができます。しかし、私自身はロシアに住んでおり、ここの人々はソフトウェアを購入するよりもはるかに緊急の費用項目があることを理解しています。したがって、このゲームが気に入っていて、経済的に私をサポートする経済力がない場合は、サイトのフィードバックフォームを使用して無料でキーを要求できます。自己紹介を怠らないでください。さもないと、wertwq @ mail.ruからの「キーをくれ」のような文字が私の中にネガティブな感情を引き起こします。

ゲームは商業化され、おそらくすぐに私以外の誰かがそれを必要としているかどうかを知るでしょう。

おそらく、この哲学的なメモで、私は私の話を終えるでしょう。この記事を読んで示された驚くべき意志力と、私の「文学的な才能」に対する見下した態度に皆に感謝します。

敬具、アレクセイ・セドフ(別名Odin_KG)

PS
ゲームに関する記事を英語に翻訳したい、少なくとも最初の部分パス検索アルゴリズム

誰かが英語について十分な知識があり、少なくとも私が話そうとしていることについてある程度理解し、この問題で私を助けたいという願望を持っているなら、私はとても幸せです。どういうわけか翻訳者を雇おうとしましたが、彼は文法的に正しい意味的なナンセンスを私に与えてくれたので、別の同様の試みをする特別な欲求はありません。

誰かが応答し、実際に仕事に取り掛かる場合、このリクエストを記事から削除します。

記事の冒頭:ゲームの復活
記事の続き:GUI

Source: https://habr.com/ru/post/J328118/


All Articles