HSP-TECH 第11弾

Network Game マルチプレイヤー処理

2001.5.3 Written & Programed by Smith


 前回まででサーバにクライアントがゲームのエントリを完了するまでを解説しました。今回はいよいよマルチプレイヤーの処理を解説することになります。ようやくネットワークゲーム製作の本題という感じですね。今回解説するマルチプレイヤー処理のアルゴリズムは私なりに考えたものであり、数々の市販されているネットワークゲームがこのアルゴリズムというわけではありません。ここで解説するものよりもっとすばらしく効率的なアルゴリズムであるかもしれませんし、実はもっとシンプルかもしれません。とりあえず、たくさんある中のひとつ、スミスなりのアルゴリズムであるということで進めていきます。

 それでは、まずはじめにプレイヤーがキャラクターを操作したときのその状態をどのようなタイミングでクライアントからサーバへ伝達すれば良いのか、これを考えてみたいと思います。とりあえず次の図を見てください。

図A

図B


 図Aは、実際のスクリーンショットを元にしたもので、Step1の時点からユーザがキー入力を開始し、Step3の時までキー入力がされ続けた時のキャラクターが移動している模様を表してます。ここでHSP-TECH7や8の頃のサンプルScriptを思い出してください。Step1からStep3まではプログラムとしてどのような処理をしてましたでしょうか。キー入力により移動要求されその方向に進めると判断された場合、32x32ドット=1フィールド(1マス)で構成されたマップ上をキャラクターが16Stepかけて1フィールド分の移動をするのでしたね。図AのStep1からStep3はこれをちょうど2回処理し終えたところです。


 では図AのStep1からStep3のどの時点の状態をどのタイミングでサーバへキャラクターの情報(状態)を伝えれば良いのでしょうか。これを示しているのが図Bになります。まずStep1の状態が停止状態だとします。これに対しユーザがキー入力をした時、要求の方向に進めると判断した場合がサーバへの通信トリガー(引き金)となります。このトリガーによってキャラクターの現在地(マップ系座標XY)と進行方向をサーバへ伝えます。その後Step2の状態(1フィールド進んだ)になるまではキャラクターの移動に関する情報をサーバへ伝える必要はありません。なぜでしょうか?先にも説明した通り、「キー入力により移動要求されその方向に進めると判断された場合1フィールド進む」というこのゲームでのルール(仕様)があるからです。このルールはサーバと全クライアントで守られるべきものです。こういった情報のことを決定性データと言うことにします。完全に決定ではありませんがルールに沿ったものであり、これは「予測できる情報」なのです。サーバ含め他のクライアントでこの動作を予測できるのですから、1フィールドの移動が完了する間は情報を送る必要がありませんね。当たり前のようですが、送る必要のない情報を送らないということは通信トラフィックの軽減、強いては情報が集中するサーバ側の負荷軽減につながり、これがネットワークレイテンシの向上(最適化)の第一歩と言えます。

なんだかむつかしい説明になってしまいましたが、Step1以降キー入力され続けても、通信タイミングはキャラクターがフィールド移動を開始するそのときをトリガーとし続ければ良いわけです。では今の説明をまとめてみます。



 キャラクター移動の流れ(決定性データの通信トリガー)   1)ユーザからキー入力あり   2)要求されその方向に進めると判断された   3)マップ系現座標XYと進行方向を情報として送信   4)1フィールドの移動処理(16Stepで32ドットを移動)   5)キーが押され続けていれば2)から繰り返し

 さて、ここまで読んで理解できるとおそらくこのような考えも浮かぶ人も多いかと思います。それは、「通信データの内容はキャラクタの座標と方向ではなく、ユーザに入力されたキー情報そのものでも良いのでは?」、「通信データはキー情報にして移動の開始と移動終了(キー情報なし)だけ送信すれば良いじゃないか」、これもひとつの方法としてアリだと思います。しかしこれではいくつかうまくないことも生まれてきます。たとえばキー入力は、なにもキャラクター移動の指示だけではなくチャットの会話入力であったり、特殊命令(メニュー)のキー入力であったり、はたまた移動開始の情報を送信後、不慮によりクライアントが終了した、移動開始のキー入力情報がネットワーク上で失われた、キー入力なし(キーを離した状態)の情報がネットワーク上を流れる間に失われたなどなど。これら諸々を考慮すると、通信情報は座標と進行方向であるほうがよりベターだということにうっすらと気づくと思います。

 それでは、実際にキャラクタが移動する際のクライアント側の通信処理プログラムを見てみましょう。

< Client >
*DP_Player_Move
;///// Playerキャラクタが移動したことをサーバへ通知 /////
Set_DataType = Player_Move ;データタイプの設定
AMdplaySend my_data, send_size, pflg, serverID : m_ec
return

 実はたったのこれだけです。データタイプにPlayer_Moveをセットし、AMdplaySend命令によりキャラクタ情報(my_data)をサーバへ送信しています。send_sizeは、配列変数my_dataの要素数が代入されてます。pflgは、通信フラグがセットされてます。serverIDは、その名の如くゲームサーバのDirectPlayID(DPID)が代入されてます。クライアントにとっての通信相手は常にゲームサーバであり、これがC/S型のプログラミングと設計における特徴のひとつと言えます。それでは対してゲームサーバ側ではこの通信データをどのように処理するのでしょう。


< Server >
*Receive_Check
;///// 受信チェック /////
result = 0 : AMdplayReceive result:m_ec
if result==-1 : goto *Receive_System_Msg
if result!1 : return
 ;///// データタイプの判断 /////
           if info_0 == Entry_Play : goto *Entry_Player
           if info_0 == Player_Move : goto *DP_Player_Move
return
*DP_Player_Move
;///// Playerキャラクタが移動した /////
tmp=bufnum.12:sender_ID=bufnum.11 ;送信元DPIDとNo.の一時保持
mx.tmp = bufnum.0 ;MAP座標系x座標
my.tmp = bufnum.1 ;MAP座標系y座標
ph.tmp = bufnum.2 ;キャラの向き
pa.tmp = bufnum.3 ;キャラのアニメーション番号
pc.tmp = bufnum.4 ;キャラのアニメーションカウント
mf.tmp = bufnum.5 ;移動中フラグ
mc.tmp = bufnum.6 ;移動中のカウント
mp.tmp = bufnum.7 ;移動中1step分の増分
dmy1.tmp=bufnum.8 ;未使用
dmy2.tmp=bufnum.9 ;未使用
ps.tmp = bufnum.10 ;キャラクタ種別番号
pid.tmp= bufnum.11 ;DirectPlayID(DPID)
pno.tmp= bufnum.12 ;PlayerNo.
 ;///// 送信元以外の全員に情報を送る
           Set_DataType = Player_Move ;データタイプの設定
           repeat PlayerMAX ;最大Player分繰り返し
                   if pid.cnt==0:continue
                   if sender_ID==pid.cnt:continue ;送信元には返さない
                   AMdplaySend bufnum, send_size_p, pflg, pid.cnt
                   await
           loop
return

 クライアント側と比較して少々長いですが、処理内容でむつかしいことはしてません。受信チェック処理によりキャラクタの移動であると判断した後にこのルーチンで処理することになりますが、通信バッファとしてセットした変数bufnumに入っている受信データをキャラクタ情報が格納されている各変数に展開することでサーバ側はこの情報を保持でき、そしてこの情報を送信元を除く全クライアントへ転送してあげます。ここでは送信元を除く全クライアントへ転送という大雑把な処理で済ましてますが、もし広大なフィールドであった場合、本来は送信元のキャラクタの近辺にいるキャラクタのクライアントだけに転送させるように組んでください。でないとゲームに参加しているプレイヤーが多くなるにつれ通信量が増大しますし、無駄な通信となっていきます。あと、AMdplaySend命令のp4(パラメータの4番目)にpid.cntとありますが、これはエントリ処理時に採番したPlayerNo.に沿って各クライアントのDPIDが格納されています。それでは今度はこのサーバからの通信データを受け取ったクライアントではどのような処理をするのでしょうか、まずはその部分のルーチンを見てみましょう。


< Client >
*Receive_Check
;///// 受信チェック /////
result = 0 : AMdplayReceive result
if result!1 : return
 ;///// データタイプの判断 /////
           if info_0 == Player_Move : goto *Receive_Player_Move
           if info_0 == Delete_Player : goto *DP_Delete_Player
           if info_0 == Entry_Player : goto *DP_Entry_Player
return
*Receive_Player_Move
;///// 他のPlayer情報の展開処理 /////
tmp=bufnum.12
if tmp==MyNo:return
ot_mx.tmp = bufnum.0 ;MAP座標系x座標
ot_my.tmp = bufnum.1 ;MAP座標系y座標
ot_ph.tmp = bufnum.2 ;キャラの向き
ot_pa.tmp = bufnum.3 ;キャラのアニメーション番号
ot_pc.tmp = bufnum.4 ;キャラのアニメーションカウント
ot_mf.tmp = bufnum.5 ;移動中フラグ
ot_mc.tmp = bufnum.6 ;移動中のカウント
ot_mp.tmp = bufnum.7 ;移動中1step分の増分
ot_dmy1.tmp=bufnum.8 ;未使用
ot_dmy2.tmp=bufnum.9 ;未使用
ot_ps.tmp = bufnum.10 ;キャラクタ種別番号
ot_pid.tmp= bufnum.11 ;DirectPlayID(DPID)
ot_pno.tmp= bufnum.12 ;PlayerNo.
return

 見てすぐに気づくと思いますが、実は先ほどのサーバ側の処理とやっていることは大差ありません。受信チェックでデータタイプを判断した後に、受信バッファに入ったデータを他プレイヤーキャラクタ用の変数に展開しているだけです。では次は通信処理から少し離れて、クライアント側における他プレイヤーキャラクタの再現プログラムを見てみましょう。


< Client >
*Other_Player_Turn
;<<<<<<<<<< 他のPlayer処理 >>>>>>>>>>
repeat PlayerMAX
if ot_pid.cnt==0:continue
if ot_mf.cnt:fcnt=cnt:gosub *Other_Player_Move
loop
gosub *Other_Player_CHR_DRAW
return

 他のプレイヤーキャラクタの再現処理をひとつのターンとして一気に処理します。ここではPlayerMAX分(最大参加可能数)のループ内でまずは、条件判断その1としてDPID=0のPlayerNo.はゲームに参加していないことになりますからループを進める、条件判断その2としてキャラクタが移動中かどうかの判断をし移動中ならば移動処理を行うという感じです。このループ終了後に一気に他プレイヤーキャラクタの描画を行います。このあたりはHSP-TECH8で解説したモンスターの処理と同じような感覚ですね。では次に移動中のキャラクタをどのように処理するか見てみます。

< Client >
*Other_Player_Move
;<<<<<<<<<< 他のPlayerの移動計算 >>>>>>>>>>
tmp=ot_ph.fcnt
ot_mc.fcnt++:ot_xv.fcnt+=hox.tmp*ot_mp.fcnt:ot_yv.fcnt+=hoy.tmp*ot_mp.fcnt
ot_pc.fcnt++:if ot_pc.fcnt>5:ot_pc.fcnt=0:ot_pa.fcnt++
if ot_pa.fcnt>2:ot_pa.fcnt=0
if ot_mc.fcnt>15{
ot_mx.fcnt+=hox.tmp:ot_my.fcnt+=hoy.tmp
ot_xv.fcnt=0:ot_yv.fcnt=0:ot_mf.fcnt=0:ot_mc.fcnt=0
ot_pc.fcnt=0:ot_pa.fcnt=1
}
return

 この部分の処理が冒頭の説明にあった「決定性データ」を予測して再現させる部分です。もう少し高度な予測もしてあげちゃったりできますが、ここではシンプルにいきます。また、他プレイヤーキャラクタの移動処理といってもなんてことはなく、要は自分自身の処理と同様に16Stepで1フィールドを移動し終える計算を元に処理しているだけです。そして次に描画処理となりますが、この描画処理はHSP-TECH8で解説したモンスター処理の描画と同じようなものです。よってここでは割愛し、再び通信関連の処理を説明します。次のScriptは小粒でピリリと辛いくらい重要な処理だったりします。実は次の処理を入れないと結構まぬけなことになるんです。


< Server >
	;/////	エントリしたPlayerにすでにログオン済みのPlayer情報を返す
Set_DataType = Player_Move ;データタイプの設定
repeat PlayerMAX ;最大Player分繰り返し
if pid.cnt==0:continue
if tmp==cnt:continue ;送信元のデータは送らない
j=cnt
repeat send_size_p ;Playerデータを通信用に再構築
send_player_data.cnt = player_data.j.cnt
loop
AMdplaySend send_player_data, send_size_p, pflg, pid.tmp : m_ec
await
loop

 この処理はなくてもプログラムは正常に動きますが、無いとゲームとしては異常事態となります。処理内容をご説明しますと、新たに参加してきたクライアントに対し、すでにゲームに参加しているプレイヤーの情報(キャラクタ情報)を送信してあげてます。この処理が無いと一体どういう事態になるのでしょうか。と考えてすぐにピンときた方はちょっと凄いかもしれませんね。なんせこういう処理はスタンドアローンなゲーム(ネットワークを使わない単体のゲーム)では考える必要もプログラムとして組む必要もないからです。で、この処理がないと、新規参加したクライアントはすでに参加しているプレイヤーの存在をすぐに感じることができず、参加済みのプレイヤーが何一つ動かなければ、新規参加者からみるとだれも居ない状態になります。また、参加済みのプレイヤーがキャラクタを動かすことでようやくそのキャラクタの存在を知ることになるのですが、これが見た目では突然自分のキャラクタの隣に出現したりと、お化けのような演出になってしまいます。なぜこの処理が重要かおわかりいただけたでしょうか。次にもうひとつ重要な処理を見てみましょう。

< Server >
	;更新されたプレイヤー名リストを全員に送る
tmpmsg=""
repeat PlayerMAX
tmpmsg+=player_name.cnt+"|" ;"|"は区切り文字
loop
Set_DataProperty = AMDPLAY_SEND_STRING ;通信フラグを文字列送信に設定
Set_DataType = Send_Entry_Player ;Playerがエントリされた時のデータタイプに設定
AMdplaySend tmpmsg,,pflg,DPID_ALLPLAYERS : m_ec
await
Set_DataProperty = AMDPLAY_SEND_ARRAY_AUTO ;通信フラグを通常に戻しておく

 この処理は、新規参加者のプレイヤー名(キャラクタ名)を含めた全参加者の名前をリストとして各クライアントに送信しているものです。これも先ほどと同様に結構重要でして、他のプレイヤー名の判別がつかないとキャラクター画像を見ただけではコンピュータなのか人間なのかわかりません。特にこの処理はチャット機能を導入するときに必要になってきます。で、プログラムを見てもらうとお分かりの通り、結構しょぼいことしてまして、各プレイヤー名を”|”(パイプライン)の文字を区切り文字にひとつの文字列としてまとめ、それを全クライアントに送信してます。この処理ではAMdplaySend命令にAMDPLAY_SEND_STRINGのフラグつまり文字列扱いの通信フラグで処理してます。

 さあ、以上で今回のHSP-TECHは終了ですが、サンプルScriptにはここでは解説しなかった処理もいくつかありますので、是非サンプルScriptをテストランさせそしてじっくりとScriptを理解してみてください。次回はチャット機能を導入します。チャット機能があるだけでいっぱしのネットワークコミュニケーションツールとしても使えるくらいのレベルになります。どうぞお楽しみに。

▽今回のスクリプトのセットをDLする(75kb)▽

次のHSP-TECHへ進む