MMO RPGの開発は実用的なガイドです。 サーバー(パート2)

リチウムを繰り返します

今日は、MMO RPG スターゴーストを例として使用して、オンラインゲーム用のC ++ゲームバックエンドのアーキテクチャと実装機能を引き続き検討します。 これは、サーバーに関する記事の2番目の部分です 。最初はここで読むことができます



AIモジュール。



通常、AIの実装はかなり複雑なプロセスです。 しかし、主にアクションを使用することで、それを「小さな血」にすることができました。 実際、AIは、どこかを飛行し、リソースを収集し、他の宇宙船を攻撃し、システム間を移動できる状態マシンを表しています。 AIの作成時には、これらすべてのアクションの実装は、プレイヤーの船を制御するためのアクションに既に含まれていました。 つまり、TBaseAIの記述はすべて、ステートマシンとこのマシン自体のデータベースからのロードデータの作成であり、実装は非常に簡単です。

「ボス」、「ゴールデンボス」、「クイーンスウォーム」などのモンスターを導入して初めて、少しの困難が生じました。 彼らだけが利用できる特定のスキルを持っています。 これらのスキルの実装は、すべてAIクラス(TBossBaseAI、TGoldenBossAI、およびTMotherOfNomadAI)で行われます。

また、AIの場合、TBaseAIのインスタンスを含み、TBaseAI :: UpdateからUpdateを呼び出すTSpaceShipの子孫であるTAISpaceShipクラスを作成する必要がありました。

AIは、何が起きているか、たとえば攻撃されたというメッセージを受信する必要があります。 これを行うために、私たちは彼をISpaceShipNotifyReciverの子孫にし、TSpaceShipは必要なデータを彼に送信します。 言い換えれば、適切なアーキテクチャ上のソリューションにより、Spaceモジュールと船の所有者(プレイヤーであろうとAIであろうと)との通信を完全に統一することができました。

結論として、図4にクラス図を示します(わかりやすくするために、図は多少簡略化されています)。



AIモジュールクラス図



クエストモジュール。



クエストシステムの実装を選択する段階で、最初に考えたのはLUAなどのスクリプト言語を使用することでした。これにより、「何でも書く」ことができます。 しかし、LUAで作業するには、メソッドとコールバックをLUAマシン自体にエクスポートする必要があります。これは非常に不便であり、メディエーター以外の何もしない多くの追加コードを書くことにつながります。 私たちのクエストシステムは非常にシンプルであるため(結果として25チームしかありません)、同時にクエストは1つしか存在できず、クエストにブランチはないため、クエストパーサーを作成することにしました。 以下は、プロローグtutor.xmqのクエストファイルからの例です。



show reel 1 then flyto 1000 -1900 insys StartSystem then show reel 2 then flyto 950 -1450 insys StartSystem then show reel 3 then spawn_oku ship 1 000_nrds near_user -400 200 insys StartSystem
      
      





オフハンドで、すべてが明確です:ビデオ1を表示し、座標1000のポイントに飛びます;開始システムで-1900、次にビデオ2を表示します。 このファイルは非常に単純なため、ゲームデザイナーでさえ、クエストの編集と追加、および必要なパラメーターのバランスを取る方法を彼に教えることができました。

建築的には、このように見えます。 クエストファイルを実際に解析し、TQuestConditionの下位クラスのファクトリを含むTQuestParserクラスがあります。 各チームには、必要な機能を実装するTQuestConditionの独自の子孫があります。 この場合、クエストコマンドクラス自体にはデータが含まれておらず(クエストファイルから直接読み込まれたものを除く)、すべてのメソッドはconstとして宣言されています。 データはTQuestクラスに含まれています。 これにより、特定のユーザーに固有の必要なデータ(特定のタイプの遊牧民をすでに殺した数など)を「スリップ」して、クエストチームのコピーを1つだけ含めることができます。 また、データベースへのクエストデータの保存を簡素化します-それらはすべて1か所で収集されます。

TQuestオブジェクトの所有者オブジェクトは、IQuestUserインターフェイス(AddQuestMoneyなどのコマンドを含む)を実装し、イベントをTQuestに報告する必要があります(たとえば、船が破壊されると、署名付きのメッセージがTQuestに送信され、クエストチームが比較できるようになります)これが破壊する必要がある船であるかどうか)。 TQuestはこのイベントを直接クエストチームに転送し、クエストチームが終了した場合、次のチームに続行します。

一般に、このモジュールは非常に単純なので、クラス図を作成しても意味がありません:)。



RPCクライアント<->サーバー(およびパケットモジュール)。



ネットワークサブシステムを設計するとき、最初の要望はjsonまたはAMFを使用することでした(クライアントがフラッシュ上にあり、これがネイティブバイナリフラッシュ形式であるため)。 ほとんどすぐに、これらのアイデアは両方とも破棄されました:ゲームはリアルタイムでTCP接続が使用されるため、パケットサイズを最小化して、パケット損失(およびその再送信、TCPはまだです)の可能性を最小限に抑える必要があります。 TCPでパケットを失い、それを中継することは、遅れを引き起こす可能性があるかなり長いプロセスです。 もちろん、私はこれを避けたかった。 2番目に重要な点は、ネットワークカードの帯域幅の制限です。 これはばかげているように思えるかもしれませんが、イベントベースではなくjsonとポーリングベースのシステムを使用しているため、開発者がネットワークカードの帯域幅に達した1つのゲームを監査する必要がありました。 その後、jsonの読み取り可能で美しい名前のフィールドはすべて、A、Bなどのスタイルで呼び出す必要がありました。 パケットサイズを最小化するため。 AMFに関しては、これはAdobeのクローズド形式であるため、私たちはそれを混乱させないことにしました-彼らが何を変更することを決定したかは決してわかりません。そして問題を探します。

その結果、非常にシンプルなパッケージ形式を実装しました。 パケットの全長とパケットのタイプを含むヘッダーで構成されます。 ただし、データ構造自体をバイナリ形式でパック/アンパックするコード、および着信パケットに関する信号も必要です。 そして、サーバーとクライアントの両方で同じ方法でそれを行います。 これらすべてのコードを2つの言語(クライアントとサーバー)で手で記述し、それを維持するのは面倒です。 そのため、すべてのパッケージの説明を含むXMLを受け入れ、それらからクライアントとサーバーに必要なクラスを生成するスクリプトをPHPで作成しました。 実際のパッケージクラス自体を生成してシリアル化することに加えて、サーバー用に別の特別な追加クラスTStdUserProcessorも生成されます。 このクラスには、各タイプのパッケージのコールバックが含まれており(これにより、作業のこの段階で受信パケットのタイプを集中制御できます)、各コールバックはパッケージクラスのインスタンスを作成し、バイナリデータをロードしてからハンドラーを呼び出します。 コードでは、次のようになります。



 virtual void OnClientLoginPacket(TClientLoginPacket& val)=0; void OnClientLoginPacketRecived(TByteOStream& ba); void TStdUserProcessor::OnClientLoginPacketRecived(TByteOStream& ba) { TClientLoginPacket p; ba>>p; OnClientLoginPacket(p); }
      
      





つまり、TStdUserProcessorの子孫クラスに対して、透過的なブリッジ「クライアント<->サーバー」が実装されます。クライアントからのパケットの送信は、TUserProcessorの単純なメソッド呼び出しです。

そして、誰がこれらのコールバックを呼び出しますか? TStdUserProcessorはTBaseUserProcessorクラスの子孫であり、m_xSocket.Recvを実行し、バイナリストリームをパケットに分割し、ヘッダーでパケットタイプを見つけ、このタイプで必要なコールバックを見つけます。 次のようになります。



 void TStdUserProcessor::AddCallbacks() { AddCallback( NNNetworkPackets::ClientLogin, &TStdUserProcessor::OnClientLoginPacketRecived ); } void TBaseUserProcessor::RecvData() { if( !m_xSocket || m_xSocket->State()!=NNSocketState::Connected ) return; if( !m_xSocket->AvailData() ) return; m_xReciver.RecvData(); if( !m_xReciver.IsPacketRecived() ) return; //         ,  ""    int type = m_xReciver.Data()->Type; if( type>=int(m_vCallbacks.size()) ) _ERROR("NoCallback for class "<<type); SLocalCallbackType cb = m_vCallbacks[type]; if(cb==NULL) _ERROR("NoCallback for class "<<type); TStdUserProcessor* c_ptr = (TStdUserProcessor*)this; const uint8* data_ptr = (const uint8*)m_xReciver.Data(); data_ptr += sizeof(TNetworkPacket); TByteOStream byte_os( data_ptr, m_xReciver.Data()->Size-sizeof(TNetworkPacket) ); if( m_xIgnoreAllPackets==0 ) { (*c_ptr.*cb)( byte_os ); } m_xReciver.ClearPacket(); }
      
      







ソケットモデル



次に、おそらく最も興味深いもの、つまり使用されているソケットモデルについて説明します。 ソケットの操作には、非同期とマルチスレッドの2つの「クラシック」アプローチがあります。 1つ目は、一般に、マルチスレッドよりも高速であり(ストリームのコンテキストを切り替える必要がないため)、問題はほとんどありません。すべてが1つのストリームであり、データの同期解除またはデッドロックの問題はありません。 2番目の方法では、ユーザーアクションに対する応答が速くなります(すべてのリソースが多数のスレッドによって消費されたわけではない場合)が、マルチスレッドデータアクセスに関する多くの問題が発生します。 これらのアプローチはどれも私たちに適していなかったため、非同期マルチスレッドの混合モデルを選択しました。 詳しく説明します。

「スターゴースト」 -スペースに関するゲーム。ゲームの世界は最初は場所に分かれています。 各ソーラーシステムは別々の場所にあり、システム間の移動はハイパーゲートの助けを借りて行われます。 このゲーム世界の分割により、アーキテクチャソリューションが必要になりました。各ソーラーシステムに個別のストリームが割り当てられ、このストリーム内のソケットの処理は非同期に実行されます。 また、惑星と遷移状態(ハイパースペース、データのロード/アンロードなど)を処理するための複数のスレッドを作成します。 実際、ハイパージャンプとは、ユーザーのオブジェクトがスレッド間を移動することです。 このようなアーキテクチャソリューションを使用すると、システムを簡単に拡張できるだけでなく(各ソーラーシステムに個別のサーバーを割り当てることができます)、同じソーラーシステム内でのユーザーインタラクションが大幅に簡素化されます。 しかし、重要なのはゲームのための宇宙での飛行と戦闘です。 ボーナスは、マルチコアアーキテクチャの自動使用と、同期オブジェクトのほぼ完全な欠如です。異なるシステムのプレイヤーは、実際には相互にやり取りせず、同じシステム内で同じスレッド内にいます。



データベースを操作します。



「スターゴースト」では 、プレーヤーのすべてのデータ(および実際すべてのデータ)は、セッションが終了するまでメモリに保存されます。 また、データベースへの保存は、セッションが完了したとき(たとえば、プレーヤーがゲームを離れたとき)にのみ行われます。 これにより、データベースの負荷を大幅に削減できます。 また、TSQLObjectオブジェクトはフィールドの変更をチェックし、実際に変更されたフィールドに対してのみ更新を行います。 次のように実装されます。 データベースからロードすると、オブジェクト内のダウンロードされたすべてのデータのコピーが作成されます。 SaveToDB()が呼び出されると、どのフィールドが最初にロードされた値と等しくないかを確認するチェックが実行され、それらのみがリクエストに追加されます。 データベースでUPDATEを実行すると、フィールドのコピーも新しい値で更新されます。

MySQLはINSERTコマンドをUPDATEコマンドよりも長く実行するため、INSERTの数を削減しようとしました。 データベースにユーザーデータを保存する最初の実装では、データベース内のすべてのアイテムのデータが消去され、再入力されました。 非常に迅速に、プレイヤーは数百(および数千)のアイテムを蓄積し、そのような操作は非常に高価で長くなりました。 その後、アルゴリズムを変更する必要がありました-変更されていないオブジェクトに触れないでください。 さらに、INSERTをすぐに実行する必要がある新しいオブジェクトは、削除のために署名された場所を見つけようとし、INSERT / DELETEのペアを呼び出さずに、UPDATEを実行します。

それとは別に、値「NULL」をデータベースに書き込むことについて言わなければなりません。 TSQLObjectの実装の特性により、データベースに対して「NULL」を読み書きすることはできません。 クラスのフィールドのタイプが「int」の場合、「NULL」は「0」として書き込まれます。したがって、データベースのUPDATEクエリに含まれるのは「0」です(本来「NULL」ではありません)。 そして、これは問題につながる可能性があります-または、データが間違っているか、このデータベースフィールドが外部キーである場合、リクエストは完全に誤っています。 この問題を解決するには、更新前にトリガーを必要なテーブルに追加する必要がありました。これにより、「0」から「NULL」になります。



データベースへのオブジェクトの保存/ロード。



C ++の問題の1つは、実行時にフィールドの文字列名を認識できず、文字列名でフィールドにアクセスできないことです。 たとえば、ActionScriptでは、オブジェクトのすべてのフィールドの名前を簡単に検索したり、メソッドを呼び出したり、フィールドにアクセスしたりできます。 このメカニズムにより、データベースでの作業を大幅に簡素化できます。クラスごとに個別のコードを記述したり、データベースに保存/ロードする必要があるフィールドをリストしたり、どのテーブルでこれを行うかをリストする必要はありません。 幸いなことに、C ++にはテンプレートなどの強力なメカニズムがあり、<cxxabi>とともに、データベースを操作するタスクに適用されるReflection APIの欠如の問題を解決できます。



Reflectionライブラリ(どのように機能するか、以下で分析します)を使用すると、次のようになります。

  1. TSQLObjectクラスから継承する必要があります。
  2. パブリッククラスの子孫にDECL_SQL_DISPATCH_TABLE()を記述する必要があります。 (これはマクロです)。
  3. 下位クラスの.cppファイルで、クラスのどのフィールドがテーブルのどのフィールドに表示されるか、クラス自体の名前、データベース内のテーブルの名前をリストします。 TDeviceクラスを例として使用すると、次のようになります。



     BEGIN_SQL_DISPATCH_TABLE(TDevice, device) ADD_SQL_FIELD(PrototypeID, m_iPrototypeID) ADD_SQL_FIELD(SharpeningCount, m_iSharpeningCount) ADD_SQL_FIELD(RepairCount, m_iRepairCount) ADD_SQL_FIELD(CurrentStructure, m_iCurrentStructure) ADD_SQL_FIELD(DispData, m_sSQLDispData) ADD_SQL_FIELD(MicromoduleData, m_sMicromodule) ADD_SQL_FIELD(AuthorSign, m_sAuthorSign) ADD_SQL_FIELD(Flags, m_iFlags) END_SQL_DISPATCH_TABLE()
          
          





  4. これで、実行時にvoid LoadFromDB(int id)、void SaveToDB()、およびvoid DeleteFromDB()メソッドを呼び出すことができます。 呼び出されると、デバイスデータベーステーブルへの対応するSQLクエリが生成され、段落3で指定されたフィールドのデータがロード/保存されます。




すべてのReflectionの作業は、次のアイデアに基づいていません。

  1. <cxxabi>を使用して、フィールドへのポインターを使用すると、このフィールドのタイプの文字列名を取得できます。 また、クラス-その祖先のリスト。
  2. オブジェクトへのポインターを作成し、それを0に等しくし、このポインターからフィールドへのポインターを取得すると、オブジェクトへのポインターに対するこのフィールドのオフセットを取得します。 もちろん、仮想継承が適用される場合、これは機能しない可能性があります。そのため、そのようなクラスにはリフレクションを注意して適用する必要があります。
  3. 1つのテンプレートクラスを使用すると、演算子new、delete、=、および==が定義されている任意のタイプに対して、このタイプのオブジェクトを作成、削除、割り当て、および比較できるファクトリを作成できます。 このファクトリーに、オブジェクトへのポインターを取得する祖先を追加します。仮想メソッドは、型指定されたものではなく、型voidではなく、static_castをテンプレート自体に追加します。 そして、オブジェクトのタイプを知らなくてもオブジェクトを操作する機会が得られます。




次に、マクロの内部を見てください。



マクロDECL_SQL_DISPATCH_TABLE()は次のことを行います。





マクロBEGIN_SQL_DISPATCH_TABLE(ClassType、TableName); 次のことを行います。





マクロADD_SQL_FIELD(VisibleName、InternalName); 次のことを行います。





背後には、実際のタイプファクトリの作成、コンバーター文字列<->オブジェクトの作成、およびすべてのReflectionデータの格納場所があります。 ストレージには、シングルトンクラスTGlobalDispatchがあります。 コンストラクターの同じクラスは、ほとんどの単純型のファクトリーとストリングコンバーターを初期化します。

TSQLObjectの動作は、<cxxabi>を使用すると、実行時にオブジェクトの実際の文字列名を取得できるという考えに基づいています。 この名前で、TGlobalDispatchに、このクラスとその祖先のすべての公開されたフィールドのリストを要求します。 テーブル名は、SQLTableName()を呼び出すことで取得できます。 フィールドの文字列コンバーターもTGlobalDispatchによって提供されます。 これで、必要なSQLクエリを簡単に作成し、オブジェクトをロード/アンロードできます。



DBおよびアイテムID。



ゲーム内のすべてのアイテムには、アイテムを識別できる一意のIDがあります。 ただし、データはセッションの最後にのみデータベースに保存され、アイテムはいつでも作成できます。 アイテムIDをどうしますか? データベースレベルでデータ整合性チェックを削除し(AUTO_INCREMENTおよびPRIMARY KEYを無効にし)、C ++レベルで一意のキーを生成できます。 しかし、これは悪い方法です。 まず、PHPの管理パネルからプレーヤーのアイテムを追加/収集/表示することはできません。このためにC ++でいくつかの追加コードを記述する必要があります。 また、サーバーでエラーが発生する可能性は、DBMSで発生する確率よりもかなり高くなります。 エラーの結果、データの整合性が失われる可能性があります(結局、整合性はデータベースによって制御されなくなりました)。 そして、この整合性は、「今日ノックアウトしたスーパークロスを失いました」というプレイヤーの大声で、手動で復元する必要があります。 一般に、データベースに格納されているオブジェクトのIDは、ゲーム内のアイテムの一意のIDと等しくなければなりません。 もう一度質問に戻ります。新しく作成されたアイテムからこのIDを取得する場所はどこですか? もちろん、アイテムをデータベースにすぐに保存できますが、これは「すべてがメモリ内にあり、セッションの最後に保存する」という考え方と矛盾し、最も重要なことは、保存が終了するまで複数のユーザーを含むストリームを停止することです。 また、停止は、ToRで指定されたプレーヤーアクションに対するサーバーの最大応答時間(50ミリ秒)を超えることがあります。 非同期ストレージには他の問題が伴います。しばらくの間、IDのないオブジェクトがあります。

IDの問題を解決するためのアイデアがすぐに思いつきました。 IDはintに設定されます。 データベースに格納されているすべてのオブジェクトのIDはゼロより大きく、新しく作成されたオブジェクトはすべてIDより小さいです。 もちろん、これはある時点でオブジェクトIDが変更され、問題が発生する可能性があることを意味します。 被験者が生きていた可能性があります。 ただし、セッションの最後に保存が行われます。アイテムはすでに破棄するためのキューにあり、アイテムで何も実行できません。



ゲームは「エルフについて」。



プレイヤーが着用できるキャラクター、スキル、魔法、ポーションを調理できるキャラクター、ロケーションを走り回って他のキャラクターを殺したり、レベルを取得したり、他のキャラクターとトレードしたりできるゲームを作成する必要があるとします。 必要なものを作成するには、現在のStar Ghostsサーバーで何をする必要がありますか?



TShipの名前をTBodyに変更すると、PCまたはNPCであるHPカーカスを攻撃できます。 TDeviceを離れましょう-それは死体(リング、マント、短剣など)に置くことができるアイテムになります。 マイクロモジュールの名前をルーンに変更すると、摩耗したギアを強化できるようになります。 TAmmoPackもそのままにしておきましょう。結局、エルフには弓があり、弓には矢が必要です。 TGoodsPackも変更なしで任意のリソースであり、すべてのウィッチャーはポーションを調理するためにあらゆる種類の花とマンドレークの根を必要とします。 魔法の問題を解決するためだけに残っています。 1つの動的パラメーターManaを枝肉(TBody)に追加し、TSpellProtoクラスとTSpellクラスを作成します。また、TCargoBayの子孫であるTSpellBookクラスを作成します。 TSpellBookでは、TSpellのみを配置し、TSpellBookをTBodyに追加できます。 呪文を唱える能力は、TShip :: FireSlotに似た方法です。 これで、エルフまたはドラゴン(またはイノシシまたは必要な人)を作成し、彼をドレスアップして、スペルブックにスペルを「書き込む」ことができます。 実際、このモジュールでのすべての変更は、クラスの名前変更と魔法を追加するための小さな編集に帰着します。



Spaceモジュールの名前をWorldに、TSystemクラスの名前をTLocationに変更します。 テレポーターを使用してロケーション間を移動します。 ご想像のとおり、テレポートは以前のTStarGateです。 次に、TSpaceObjectの名前をTWorldObjectに、TSpaceShipの名前をTWorldBodyに変更します。 これで、エルフ(またはドラゴン)が現在の座標を取得し、移動するコマンドを彼に与えることができます。 確かに、彼は障害物をチェックしませんが、回転するとき、彼は円を切って、「バレル」を作るよう努めます。 TSystemの動作と移動コマンドのロジックを完全にやり直す必要があります。これは、サーバーをエルフについてのゲームに適応させる上で最も高価で難しい部分です。



SpaceモジュールでActionsモジュールをやり直した場合、マジックを使用しなくてもAIモジュールはほとんどすぐに動作します。 NPCがマジックを使用する必要がある場合、TBaseAIレベルで特殊な武器を使用するのと同様のコードを追加する必要があります。



クエストシステムが裏切りでない限り、クエストモジュールは少し変更されます。 最大-空間内のオブジェクトのスポーン(またはその場所に既にある)で何かを変更する必要があります。



メインモジュールとパケットは実質的に変更されません。 マジックを使用するためのいくつかのパッケージがそこに追加され、格納庫が削除され、実際にはそれだけです。 クラフトレシピ、バフの種類などを置き換える-これはすべてゲーム設計作業であり、管理パネルから実行され、プログラミングとは関係ありません。



以上で、エルフに関するゲームのサーバーの準備が整いました。彼らがスケジュールを描き、クライアントを作るまで6ヶ月待つだけです:)。



All Articles