今日、node.jsが本格的なとき、私たちはすでにそれを使用してあらゆる種類のことをすることができます。しばらく前に、Up HostはThe Geek Songイベントに参加しました。このイベント中に、「ヘッドダウンの人々」がさらにコミュニケーションできるようにするゲームを作成するつもりです。コア関数は、LANパーティーの概念のリアルタイムのマルチパーソン相互作用です。オタクのレースは哀れなほど短い36時間であり、すべてが迅速かつ迅速になる必要がありました。そのような前提の下で、最初の準備は少し「自然」に見えます。クロスプラットフォームアプリケーションソリューションNode-Webkitを選択しました。これは十分にシンプルで、要件を満たしています。
要件に応じて、モジュールに従って開発は個別に実行できます。この記事では、一連の探索と試みを含むSpaceroom(リアルタイムマルチプレイヤーゲームフレームワーク)の開発プロセス、およびnode.jsおよびwebkitプラットフォーム自体のいくつかの制限の解決策、および解決策の提案について特に説明します。
はじめる
Spaceroomの視線
当初、Spaceroomのデザインは間違いなく需要主導型でした。このフレームワークが次の基本機能を提供できることを願っています。
部屋(またはチャネル)に基づいてユーザーのグループを区別できます
コレクショングループのユーザーから指示を受信できる
各クライアントを一致させるとき、ゲームデータは指定された間隔に従って正確にブロードキャストすることができます
ネットワークレイテンシの影響を可能な限り排除できます
もちろん、コーディングの後期段階では、ゲームの一時停止、各クライアント間の一貫した乱数を生成するなど、より多くの機能をSpaceroomに提供しました。もちろん、これらは要件に応じてそれ自体で実装できます。また、通信レベルで機能するフレームワークを使用する必要はありません。
API
Spaceroomは、フロントエンドとバックエンドの2つの部分に分かれています。サーバーが必要とする作業には、部屋リストの維持と、部屋の作成と参加の機能を提供することが含まれます。私たちのクライアントAPIは次のように見えます:
spaceroom.connect(アドレス、コールバック)サーバーに接続します
spaceroom.createroom(コールバック)部屋を作成します
spaceroom.joinroom(roomid)部屋に参加します
Spaceroom.on(イベント、コールバック)は、イベントの聴きます
...
クライアントがサーバーに接続した後、さまざまなイベントを受信します。たとえば、部屋のユーザーは、新しいプレーヤーが参加するイベント、またはゲームが開始されるイベントを受け取る場合があります。クライアントに「ライフサイクル」を与えます。これは、いつでも次の州のいずれかになります。
Spaceroom.stateを介してクライアントの現在のステータスを取得できます。
サーバー側のフレームワークを使用することは比較的簡単です。デフォルトの構成ファイルを使用する場合、サーバー側のフレームワークを直接実行できます。基本的な要件があります。サーバーコードは、別のサーバーを必要とせずにクライアントで直接実行できます。 PSまたはPSPをプレイしたプレイヤーは、私が話していることを知っている必要があります。もちろん、特別なサーバーで実行することも優れています。
論理コードの実装はここで簡単です。 Spaceroomの第1世代は、部屋のステータスを含む部屋のリスト、および各部屋に対応するゲーム時間通信(命令コレクション、バケットブロードキャストなど)を維持するソケットサーバーの機能を完了しました。特定の実装については、ソースコードを参照してください。
同期アルゴリズム
それでは、どのようにして、各クライアントの間に表示されたものをリアルタイムで一貫させることができますか?
このことは面白そうです。それについて注意深く考えてください、私たちが私たちが配信するのに役立つサーバーが何を必要としますか?当然のことながら、さまざまなクライアント間で論理的な矛盾を引き起こす可能性があるもの、ユーザーの指示を考えるでしょう。ゲームロジックを扱うコードは同じであるため、同じ条件を考えると、コードは同じ結果を実行します。唯一の違いは、ゲーム中に受け取ったさまざまなプレイヤーコマンドです。そう、これらの命令を同期する方法が必要です。すべてのクライアントが同じ命令を取得できる場合、すべてのクライアントは理論的に同じ操作結果を得ることができます。
オンラインゲームの同期アルゴリズムは、あらゆる種類の奇妙で適用可能なシナリオです。 Spaceroomは、フレームロックの概念と同様の同期アルゴリズムを使用します。タイムラインを1つずつ間隔に分割し、各間隔はバケツと呼ばれます。バケットは命令を読み込むために使用され、サーバー側によって維持されます。各バケット期間の終わりに、サーバーはバケツをすべてのクライアントにブロードキャストします。クライアントがバケットを取得した後、指示はそこから取得され、確認後に実行されます。
ネットワークの遅延の影響を減らすために、クライアントからサーバーが受け取った各命令は、特定のアルゴリズムに従って対応するバケットに配信され、次の手順に従って次の手順に従います。
Order_Startをコマンドによって運ばれるコマンドの時間とし、TはOrder_Startがあるバケツの開始時間です。
T + delay_time <=現在命令を収集しているバケットの開始時間、コマンドを現在命令を収集しているバケツに配信します。
T + delay_timeの対応するバケツに命令を届けます
ここで、Delay_timeは合意されたサーバーの遅延時間であり、クライアント間の平均遅延と見なすことができます。 Spaceroomのデフォルト値は80、バケット長のデフォルト値は48です。各バケット期間の終わりに、サーバーはこのバケツをすべてのクライアントにブロードキャストし、次のバケットの指示を受け取り始めます。クライアントは、受信されたバケット間隔に基づいてロジックのマッチングを自動的に実行するときに、許容範囲内の時間エラーを制御します。
これは、通常の状況では、クライアントが48msごとにサーバーから送信されるバケットを受け取ることを意味します。バケットを処理する必要があるとき、クライアントはそれに応じてそれを処理します。クライアントFPS = 60であると仮定すると、3フレームほどごとにバケツを受け取り、このバケツに従ってロジックが更新されます。ネットワークの変動により期限が切れた後にバケツが受信されていない場合、クライアントはゲームロジックを一時停止して待機します。バケット内では、LERPメソッドを使用してロジックを更新できます。
delay_time = 80の場合、bucket_size = 48の場合、いずれかの命令が少なくとも96msの実行によって遅延されます。これらの2つのパラメーターを変更します。たとえば、delay_time = 60、bucket_size = 32の場合、いずれかの命令が少なくとも64ms遅延します。
タイマーによって引き起こされる血なまぐさい事件
全体を見ると、私たちのフレームワークは、実行中に正確なタイマーを持つ必要があります。固定間隔の下でバケットのブロードキャストを実行します。もちろん、最初にSetinterval()を使用することを考えましたが、次の瞬間に、このアイデアがどれほど信頼できないかを認識しました。そして、それほど悪いことは、すべてのエラーが蓄積し、ますます深刻な結果を引き起こすことです。
そのため、すぐにsettimeout()を使用して、次の到着の時間を動的に修正することにより、指定された間隔の周りでロジックをほぼ安定させることを考えました。たとえば、今回はSettimeout()は予想よりも5ms少ないため、次回は5msを前にします。ただし、テスト結果は満足のいくものではなく、これはどのように見ても十分にエレガントではありません。
ですから、私たちは思考を変える必要があります。 Settimeout()をできるだけ早く期限切れにすることは可能ですか?その後、現在の時間が目標時間に達したかどうかを確認します。たとえば、ループでは、settimeout(callback、1)を使用して時間をチェックし続けます。これは良い考えのようです。
残念なタイマー
私たちはすぐにアイデアをテストするためにコードを書きましたが、結果は残念でした。現在の最新のnode.js安定バージョン(v0.10.32)とWindowsプラットフォームで、このコードを実行します。
コードコピーは次のとおりです。
var sum = 0、count = 0;
function test(){
var now = date.now();
setimeout(function(){
var diff = date.now() - now;
sum += diff;
count ++;
テスト();
});
}
テスト();
一定期間後、コンソールにsum/countを入力すると、次のような結果が表示されます。
コードコピーは次のとおりです。
> sum / count
15.62455160142348
何?! 1ms間隔が欲しいのですが、実際の平均間隔は15.625msであることを教えてください!この写真は単に美しすぎます。 Macで同じテストを行い、結果は1.4msでした。だから私たちは混乱していました:これは一体何ですか?もし私がアップルのファンだったら、窓はあまりにもゴミだと窓をあきらめたと結論付けたかもしれません。幸いなことに、私は厳格なフロントエンドエンジニアだったので、この数について考え続け始めました。
待って、なぜこの数はそんなに馴染みがあるのですか?この数は、Windowsの最大タイマー間隔に似ていますか?すぐにテスト用の時計をダウンロードしましたが、コンソールを実行した後、次の結果が得られました。
コードコピーは次のとおりです。
最大タイマー間隔:15.625ミリ秒
最小タイマー間隔:0.500ミリ秒
現在のタイマー間隔:1.001ミリ秒
案の定! node.jsマニュアルを見ると、このようなsettimeoutの説明を見ることができます。
実際の遅延は、OSタイマーの粒度やシステム負荷などの外部要因に依存します。
ただし、テスト結果は、この実際の遅延が最大タイマー間隔であることを示しています(システムの現在のタイマー間隔はわずか1.001msであることに注意してください)。強い好奇心は、node.jsのソースコードを調べて、真実を垣間見るようになります。
node.jsのバグ
私はあなたのほとんどと私がnode.jsの均等なループメカニズムについて特定の理解を持っていると信じています。タイマーの実装のソースコードを見ると、タイマーの実装原則を大まかに理解できます。イベントループのメインループから始めましょう:
コードコピーは次のとおりです。
while(r!= 0 && loop-> stop_flag == 0){
/*グローバル時間を更新*/
uv_update_time(loop);
/*タイマーの有効期限が切れているかどうかを確認し、対応するタイマーコールバックを実行します*/
uv_process_timers(loop);
/*何もしない場合はアイドルコールバックを呼び出します。 */
if(loop-> pending_reqs_tail == null &&
loop-> endgame_handles == null){
/*イベントループが終了するのを防ぐ*/
uv_idle_invoke(loop);
}
uv_process_reqs(loop);
uv_process_endgames(loop);
uv_prepare_invoke(loop);
/* ioイベントを収集*/
(*poll)(loop、loop-> idle_handles == null &&
loop-> pending_reqs_tail == null &&
loop-> endgame_handles == null &&
!loop-> stop_flag &&
(loop-> active_handles> 0 ||
!ngx_queue_empty(&loop-> active_reqs))&&
!(mode&uv_run_nowait));
/* setimmediate()etc*/
uv_check_invoke(loop);
r = uv__oop_alive(loop);
if(mode&(uv_run_once | uv_run_nowait)))
壊す;
}
uv_update_time関数のソースコードは次のとおりです。(https://github.com/joyent/libuv/blob/v0.10/src/win/timer.c))
コードコピーは次のとおりです。
void uv_update_time(uv_loop_t* loop){
/*現在のシステム時間を取得*/
dword ticks = getTickCount();
/ * large_integer.quadpartが同じタイプを持っているという仮定が行われます */
/*ループ - >時間、たまたま。これを主張する方法はありますか? */
large_integer* time =(large_integer*)&loop-> time;
/*タイマーがラップされている場合は、高次のDWORDに1を追加します。 */
/ * uv_pollは、タイマーが決してあふれないことを確認する必要があります */
/* 2つの後続のuv_update_time呼び出しの間に1回。 */
if(ticks <time-> lowpart){
時間 - > highpart += 1;
}
時間 - > lowpart =ティック;
}
この関数の内部実装では、WindowsのgetTickCount()関数を使用して、現在の時間を設定します。簡単に言えば、SettimeOut関数を呼び出した後、一連の闘争の後、内部タイマー - > DEVEは現在のループ時間 +タイムアウトに設定されます。イベントループでは、最初にuv_update_timeを介して現在のループの時間を更新し、次にuv_process_timesでタイマーが有効期限を切るかどうかを確認します。もしそうなら、JavaScriptの世界に入ります。記事全体を読んだ後、イベントループはこのプロセスのようになります。
グローバル時間を更新します
タイマーを確認してください。タイマーの有効期限が切れた場合は、コールバックを実行します。
reqsキューを確認し、待機中のリクエストを実行します
IOイベントを収集するには、ポーリング関数を入力します。 IOイベントが到着した場合は、次のイベントループで実行するために、対応する処理関数をREQSキューに追加します。投票関数内では、IOイベントを収集するためにシステム方法が呼び出されます。この方法により、IOイベントが到着するか、セットタイムアウト時間に到達するまで、プロセスがブロックされます。この方法が呼び出されると、タイムアウト時間は最新のタイマーが期限切れになる時間に設定されます。これは、IOイベントがブロックによって収集され、最大ブロッキング時間が次のタイマーの最終時間であることを意味します。
Windowsの下の投票機能の1つのソースコード:
コードコピーは次のとおりです。
static void uv_poll(uv_loop_t* loop、int block){
dwordバイト、タイムアウト;
ulong_ptrキー;
オーバーラップ*オーバーラップ。
uv_req_t* req;
if(block){
/*最新のタイマーの有効期限を取り出す*/
Timeout = uv_get_poll_timeout(loop);
} それ以外 {
タイムアウト= 0;
}
getqueuedcompletionstatus(loop-> iocp、
&バイト、
&鍵、
&オーバーラップ、
/*次のタイマーが失効するまで、ほとんどのブロックで*/
タイムアウト);
if(オーバーラップ){
/ *パッケージはdequeued */
req = uv_overlapped_to_req(オーバーラップ);
/* ioイベントをキューに挿入*/
uv_insert_pending_req(loop、req);
} else if(getLasterRor()!= wait_timeout){
/ *深刻なエラー */
uv_fatal_error(getlasterror()、 "getqueuedcompletionstatus");
}
}
上記の手順に従って、タイムアウト= 1MSタイマーを設定すると仮定すると、投票機能は最大1MSでブロックされ、回復後に回復します(期間中にIOイベントがない場合)。イベントループループの入力を続けると、uv_update_timeが時間を更新し、uv_process_timersはタイマーが期限切れになり、コールバックを実行することがわかります。したがって、予備分析では、UV_UPDATE_TIMEに問題がある(現在の時刻は正しく更新されない)、またはポーリング関数が1MSを待ってから回復することです。この1MSが待っていることに問題があります。
MSDNを見ると、驚くべきことに、getictcount関数の説明を発見しました。
GetCloctCount関数の解像度は、システムタイマーの解像度に限定されています。これは通常、1,000万秒から1600万秒の範囲です。
getCloctCountの精度はとても大まかです!投票機能が1msの時間を正しくブロックしますが、次にuv_update_timeが実行されると、現在のループ時間が正しく更新されないと仮定します!そのため、私たちのタイマーは期限切れであると判断されなかったため、投票はさらに1MSを待って、次のイベントループに入りました。 getTickCountが最終的に正しく更新されるまで(いわゆる15.625msが1回更新される)、ループの現在の時刻が更新され、タイマーはuv_process_timersで期限切れになると判断されます。
WebKitに助けを求めてください
Node.jsのこのソースコードは非常に無力です。彼は、精度が低い時間関数を使用し、何もしませんでした。しかし、node.jsのSettimeoutに加えて、node-webkitを使用しているため、ChromiumのSettimeoutもあるとすぐに考えました。テストコードを作成し、ブラウザまたはノードウェブキットを使用して実行します:http://marks.lrednight.com/test.html#1(##の後に、測定する間隔を示します)。結果は次のとおりです。
HTML5の仕様によると、理論的な結果は、最初の5つの結果が1msであり、次の結果は4msであるということです。テストケースに表示される結果は3回目から始まります。つまり、テーブル上のデータは最初の3回は理論的には1msであり、その後の結果は4msです。結果には特定のエラーがあり、規制によれば、得られる最小の理論的結果は4msです。私たちは満足していませんが、node.jsの結果よりも明らかにはるかに満足しています。強い好奇心のトレンドChromiumのソースコードを見て、それがどのように実装されているかを見てみましょう。 (https://chromium.googleSource.com/chromium/src.git/+/38.0.2125.101/base/time_win.cc)
まず、Chromiumはループの現在の時間を決定する際にTimeGetTime()関数を使用します。 MSDNを見ると、この関数の精度がシステムの現在のタイマー間隔の影響を受けることがわかります。私たちのテストマシンでは、理論的には上記の1.001msです。ただし、デフォルトでは、アプリケーションがグローバルタイマー間隔を変更しない限り、タイマー間隔は最大値(テストマシンで15.625ms)です。
IT業界でニュースをフォローしている場合、そのようなニュースを見たに違いありません。私たちのクロムはタイマー間隔を非常に小さく設定しているようです!システムタイマー間隔について心配する必要はないようですか?あまりにも早く幸せにならないでください、そのような修理は私たちに打撃を与えました。実際、この問題はChrome 38で修正されています。以前のNode-Webkitの修正を使用する必要がありますか?これは明らかに十分にエレガントではなく、よりパフォーマンスのあるバージョンのクロムを使用することを妨げます。
Chromiumソースコードをさらに見ると、タイマーがあり、タイムアウト<32msのタイムアウトがある場合、Chromiumはシステムのグローバルタイマー間隔を変更して、15.625m未満の精度でタイマーを実現することがわかります。 (ソースコードを表示)タイマーを起動すると、HighResolutionTimermanagerと呼ばれるものが有効になります。このクラスは、現在のデバイスの電源タイプに基づいて、enabableHighSolutionTimer関数を呼び出します。具体的には、現在のデバイスがバッテリーを使用すると、EnableHighSolutionTimer(false)を呼び出し、電源を使用するとtrueが渡されます。 EnableHighSolutionTimer関数の実装は次のとおりです。
コードコピーは次のとおりです。
void time :: enablehighResolutionTimer(bool enable){
base :: autolock lock(g_high_res_lock.get());
if(g_high_res_timer_enabled == enable)
戻る;
g_high_res_timer_enabled = enable;
if(!g_high_res_timer_count)
戻る;
// g_high_res_timer_count!= 0以来、ActivateHighSolutionTimer(true)
// g_high_res_timer_enabledを使用してtimebeginperiodと呼ばれると呼ばれました
// | enable |の反対の値で。その情報で私たち
// TimeBeginPeriodで使用されているのと同じ値でTimeEndPerioDを呼び出し、
//したがって、期間効果を元に戻します。
if(enable){
TimeEndPeriod(kmintimerintervallowresms);
TimeBeginPeriod(kmintimerintervalhighresms);
} それ以外 {
TimeEndPeriod(kmintimerintervalhighresms);
TimeBeginPeriod(kmintimerintervallowresms);
}
}
ここで、kmintimerintervallowresms = 4およびkmintimerintervalhighresms =1。timebeginperiodおよびtimeendperiodは、システムタイマー間隔を変更するためにWindowsによって提供される関数です。つまり、電源に接続する場合、取得できる最小タイマー間隔は1msですが、バッテリーを使用する場合は4msです。 W3Cの仕様によると、ループはSettimeOutを継続的に呼び出しているため、最小間隔も4msなので、安心していると感じます。これは私たちにほとんど影響を与えません。
別の精密な問題
最初に戻って、テスト結果は、SettimeOutの間隔が4msで安定していないが、継続的に変動していることを示していることがわかりました。 http://marks.lrednight.com/test.html#48テスト結果は、間隔が48msから49msの間に上昇していることも示しています。その理由は、Chromiumとnode.jsのイベントループでは、IOイベントを待機するWindows関数の正確性が現在のシステムのタイマーの影響を受けるためです。ゲームロジックの実装には、RequestAnimationFrame関数(キャンバスの絶えず更新)が必要です。これにより、少なくともKmintimerIntervallowResmsにタイマー間隔を設定するのに役立ちます(16MSタイマーが必要なため、高予測タイマーの要件がトリガーされます)。テストマシンが電力を使用する場合、システムタイマー間隔は1MSであるため、テスト結果のエラーは±1msです。コンピューターがシステムタイマー間隔を変更していない場合、上記の#48テストを実行していない場合、Maxは48+16 = 64msに達する可能性があります。
ChromiumのSettimeout実装を使用して、Settimeout(FN、1)の誤差を約4MSに制御できますが、SettimeOut(FN、48)の誤差はSettimeOut(FN、48)の誤差を約1MSに制御できます。だから、私たちの心には新しい青写真があります。これにより、コードは次のようになります。
コードコピーは次のとおりです。
/ *最大間隔の装飾を取得 */
var decoration = getmaxintervaldeviation(backetsize); // backetssize = 48、偏差= 2;
function gameloop(){
var now = date.now();
if(以前のバケット + backetsize <= now){
前bucket = now;
dologic();
}
if(forterbucket + backetsize -date.now()> decoration){
// 46msを待ちます。実際の遅延は48ms未満です。
Settimeout(Gameloop、BucketSize -Design);
} それ以外 {
//忙しい待機。前者がIOイベントをブロックしないため、process.nexttickの代わりにSetimmediateを使用します。
setimmediate(gameloop);
}
}
上記のコードでは、bucket_sizeに直接等しくなるのではなく、bucket_size(bucket_size定義)未満のエラーで時間を待つことができます。上記の理論によれば、最大誤差が46msの遅延で発生したとしても、実際の間隔は48ms未満です。残りの時間は、忙しい待機方法を使用して、Gameloopが十分な精度で間隔の下で実行されるようにします。
Chromiumで問題をある程度解決しましたが、これは明らかに十分にエレガントではありません。
最初のリクエストを覚えていますか?サーバー側のコードは、Node-WebkitクライアントなしでNode.js環境を使用してコンピューターで直接実行できるはずです。上記のコードを直接実行する場合、定義の値は少なくとも16msです。つまり、各48msで16msを待つ必要があります。 CPUの使用率は上昇しました。
予想外の驚き
とても迷惑です。 node.jsでそのような大きなバグに気づかなかった人はいませんか?答えは本当に私たちを大喜びさせます。このバグは、v.0.11.3バージョンで修正されています。また、Libuvコードのマスターブランチを直接表示することにより、変更された結果を確認することもできます。特定のアプローチは、投票機能が完了を待っていた後、ループの現在の時間にタイムアウトを追加することです。このようにして、GetTickCountが反応しなかったとしても、世論調査が待っていた後もこの待ち時間を追加しました。そのため、タイマーはスムーズに期限切れになる可能性があります。
言い換えれば、長い間一生懸命働いてきた問題は、v.0.11.3で解決されました。しかし、私たちの努力は無駄ではありませんでした。 GetCloctCount関数が排除されたとしても、投票機能自体がシステムタイマーの影響を受けるためです。解決策の1つは、node.jsプラグインを作成して、システムタイマーの間隔を変更することです。
ただし、今回のゲームの初期設定はサーバーフリーではありません。クライアントが部屋を作成すると、サーバーになります。サーバーコードはNode-Webkit環境で実行できるため、Windowsシステムでのタイマーの問題の優先順位は最高ではありません。上記のソリューションに従って、結果は私たちを満足させるのに十分です。
エンディング
タイマーの問題を解決した後、フレームワークの実装は基本的に妨げられません。 (純粋なHTML5環境で)WebSocketサポートを提供し、通信プロトコルをカスタマイズして、より高いパフォーマンスソケットサポートを実現します(Node-Webkit環境で)。もちろん、Spaceroomの機能は最初は比較的単純でしたが、需要が提案され、時間が増えたため、このフレームワークを徐々に改善しています。
たとえば、ゲームで一貫した乱数を生成する必要があるときに、この関数をSpaceroomに追加したことがわかったときです。ゲームの開始時に、Spaceroomは乱数シードを配布します。クライアントのSpaceroomは、MD5のランダム性を使用して、乱数シードの助けを借りて乱数を生成する方法を提供します。
それは非常に安心したようです。また、そのようなフレームワークを書く過程で多くのことを学びました。 Spaceroomに興味がある場合は、参加することもできます。 Spaceroomは、より多くの場所で拳と足を使用すると思います。