Node.jsでマルチプレイヤーテキストアドベンチャーエンジンを作成する:ゲームエンジンサーバーの設計(パート2)

公開: 2022-03-10
クイックサマリー↬このシリーズの第2部へようこそ。 最初の部分では、Node.jsベースのプラットフォームとクライアントアプリケーションのアーキテクチャについて説明しました。これにより、人々はグループとして独自のテキストアドベンチャーを定義してプレイできるようになります。 今回は、フェルナンドが前回定義したモジュールの1つ(ゲームエンジン)の作成について説明します。また、コーディングを開始する前に何が必要かを明らかにするために、設計プロセスにも焦点を当てます。自分の趣味のプロジェクト。

モジュールを慎重に検討して実際に実装した後、設計段階で作成した定義の一部を変更する必要がありました。 これは、理想的な製品を夢見ているが、開発チームによって抑制される必要がある熱心なクライアントと一緒に仕事をしたことがある人にとってはなじみのあるシーンであるはずです。

機能が実装およびテストされると、チームはいくつかの特性が元の計画と異なる可能性があることに気付き始めます。それで問題ありません。 通知し、調整して、続行するだけです。 ですから、これ以上面倒なことはせずに、最初に当初の計画から何が変わったのかを説明させてください。

このシリーズの他の部分

  • パート1:はじめに
  • パート3:ターミナルクライアントの作成
  • パート4:ゲームにチャットを追加する

バトルメカニッ​​クス

これはおそらく当初の計画からの最大の変更です。 関係する各PCとNPCがイニシアチブの価値を獲得し、その後、ターン制の戦闘を実行するD&D風の実装を採用するつもりだと言ったことは知っています。 いいアイデアでしたが、サーバー側から通信を開始したり、呼び出し間のステータスを維持したりできないため、RESTベースのサービスに実装するのは少し複雑です。

代わりに、RESTの単純化されたメカニズムを利用し、それを使用して戦闘メカニズムを単純化します。 実装されたバージョンは、パーティーベースではなくプレイヤーベースになり、プレイヤーがNPC(ノンプレイヤーキャラクター)を攻撃できるようになります。 彼らの攻撃が成功した場合、NPCは殺されるか、そうでなければプレイヤーにダメージを与えるか殺すことによって攻撃します。

攻撃が成功するか失敗するかは、使用する武器の種類とNPCの弱点によって決まります。 つまり、基本的に、あなたが殺そうとしているモンスターがあなたの武器に対して弱い場合、それは死にます。 そうでなければ、影響を受けず、おそらく非常に怒ります。

トリガー

前回の記事のJSONゲーム定義に細心の注意を払った場合、シーンアイテムにトリガーの定義が含まれていることに気付いたかもしれません。 特定の1つは、ゲームステータスの更新( statusUpdate )に関係していました。 実装中に、トグルとして機能することで自由度が制限されることに気づきました。 ご覧のとおり、(慣用的な観点から)実装された方法では、ステータスを設定できましたが、設定を解除することはできませんでした。 その代わりに、このトリガー効果をaddStatusremoveStatusの2つの新しいものに置き換えました。 これらにより、これらの効果がいつ発生するかを正確に定義できます。 これは理解しやすく、推論しやすいと思います。

これは、トリガーが次のようになったことを意味します。

 "triggers": [ { "action": "pickup", "effect":{ "addStatus": "has light", "target": "game" } }, { "action": "drop", "effect": { "removeStatus": "has light", "target": "game" } } ]

アイテムを拾うときはステータスを設定し、ドロップするときは削除します。 このように、複数のゲームレベルのステータスインジケーターを持つことは完全に可能であり、管理が容易です。

ジャンプした後もっと! 以下を読み続けてください↓

実装

これらの更新が邪魔にならないので、実際の実装について説明し始めることができます。 アーキテクチャの観点からは、何も変わりません。 メインのゲームエンジンのロジックを含むRESTAPIをまだ構築中です。

テックスタック

この特定のプロジェクトで使用するモジュールは次のとおりです。

モジュール説明
Express.js もちろん、エンジン全体のベースとしてExpressを使用します。
ウィンストンロギングに関するすべてはWinstonによって処理されます。
構成すべての定数および環境依存変数はconfig.jsモジュールによって処理されます。これにより、それらにアクセスするタスクが大幅に簡素化されます。
マングースこれが私たちのORMになります。 Mongooseモデルを使用してすべてのリソースをモデル化し、それを使用してデータベースと直接対話します。
uuid いくつかの一意のIDを生成する必要があります—このモジュールはそのタスクに役立ちます。

Node.js以外で使用されている他のテクノロジーについては、 MongoDBRedisがあります。 必要なスキーマがないため、Mongoを使用するのが好きです。 その単純な事実により、テーブルの構造の更新、スキーマの移行、またはデータ型の競合について心配することなく、コードとデータ形式について考えることができます。

Redisに関しては、プロジェクトでできる限りサポートシステムとして使用する傾向がありますが、この場合も例外ではありません。 パーティメンバー番号、コマンドリクエスト、および永続的なストレージに値しないほど小さくて揮発性であるその他のタイプのデータなど、揮発性の情報と見なすことができるすべてのものにRedisを使用します。

また、Redisのキー有効期限機能を使用して、フローのいくつかの側面を自動管理します(これについては後ほど詳しく説明します)。

API定義

クライアント/サーバーの相互作用とデータフローの定義に移る前に、このAPI用に定義されたエンドポイントについて説明します。 それらはそれほど多くはありませんが、ほとんどの場合、パート1で説明した主な機能に準拠する必要があります。

特徴説明
ゲームに参加するプレイヤーはゲームのIDを指定することでゲームに参加できます。
新しいゲームを作成するプレーヤーは、新しいゲームインスタンスを作成することもできます。 他の人がIDを使用して参加できるように、エンジンはIDを返す必要があります。
リターンシーンこの機能は、パーティーが行われている現在のシーンを返す必要があります。 基本的に、関連するすべての情報(可能なアクション、その中のオブジェクトなど)を含む説明が返されます。
シーンと対話するこれは、クライアントからコマンドを受け取り、そのアクション(移動、プッシュ、取得、検索、読み取りなど)を実行するため、最も複雑なものの1つになります。
在庫を確認するこれはゲームを操作する方法ですが、シーンに直接関係するものではありません。 したがって、各プレイヤーのインベントリを確認することは、異なるアクションと見なされます。
クライアントアプリケーションを登録する上記のアクションを実行するには、有効なクライアントが必要です。 このエンドポイントは、クライアントアプリケーションを検証し、後続のリクエストで認証目的で使用されるクライアントIDを返します。

上記のリストは、次のエンドポイントのリストに変換されます。

動詞終点説明
役職/clients クライアントアプリケーションは、このエンドポイントを使用してクライアントIDキーを取得する必要があります。
役職/games 新しいゲームインスタンスは、クライアントアプリケーションによってこのエンドポイントを使用して作成されます。
役職/games/:id ゲームが作成されると、このエンドポイントにより、パーティーメンバーがゲームに参加してプレイを開始できるようになります。
得る/games/:id/:playername このエンドポイントは、特定のプレーヤーの現在のゲーム状態を返します。
役職/games/:id/:playername/commands 最後に、このエンドポイントを使用すると、クライアントアプリケーションはコマンドを送信できるようになります(つまり、このエンドポイントは再生に使用されます)。

前のリストで説明したいくつかの概念についてもう少し詳しく説明します。

クライアントアプリ

クライアントアプリケーションは、システムの使用を開始するためにシステムに登録する必要があります。 すべてのエンドポイント(リストの最初のエンドポイントを除く)は保護されており、リクエストとともに送信するには有効なアプリケーションキーが必要です。 そのキーを取得するには、クライアントアプリは単にキーを要求する必要があります。 一度提供されると、それらは使用されている限り持続するか、使用されていない1か月後に期限切れになります。 この動作は、キーをRedisに保存し、それに1か月のTTLを設定することで制御されます。

ゲームインスタンス

新しいゲームを作成するということは、基本的に特定のゲームの新しいインスタンスを作成することを意味します。 この新しいインスタンスには、すべてのシーンとそのコンテンツのコピーが含まれます。 ゲームに加えられた変更は、パーティーにのみ影響します。 このようにして、多くのグループが独自の方法で同じゲームをプレイできます。

プレイヤーのゲーム状態

これは前のものと似ていますが、各プレイヤーに固有です。 ゲームインスタンスはパーティー全体のゲーム状態を保持しますが、プレーヤーのゲーム状態は1人の特定のプレーヤーの現在のステータスを保持します。 主に、これはインベントリ、位置、現在のシーン、およびHP(ヘルスポイント)を保持します。

プレイヤーコマンド

すべてがセットアップされ、クライアントアプリケーションが登録されてゲームに参加すると、コマンドの送信を開始できます。 このバージョンのエンジンに実装されているコマンドには、 movelookpickupattackが含まれます。

  • moveコマンドを使用すると、マップをトラバースできます。 移動したい方向を指定できるようになり、エンジンが結果を通知します。 パート1をざっと見てみると、私がマップを処理するために取ったアプローチを見ることができます。 (要するに、マップはグラフとして表され、各ノードは部屋またはシーンを表し、隣接する部屋を表す他のノードにのみ接続されます。)

    ノード間の距離も表現に含まれ、プレーヤーの標準速度と組み合わされます。 部屋から部屋への移動は、コマンドを述べるほど簡単ではないかもしれませんが、距離を移動する必要もあります。 実際には、これは、ある部屋から別の部屋に移動するには、いくつかの移動コマンドが必要になる可能性があることを意味します)。 このコマンドのもう1つの興味深い側面は、このエンジンがマルチプレイヤーパーティーをサポートすることを目的としており、パーティーを分割できないことです(少なくとも現時点では)。

    したがって、これに対する解決策は投票システムに似ています。すべてのパーティメンバーは、必要なときにいつでも移動コマンド要求を送信します。 それらの半分以上がそうすると、最も要求された方向が使用されます。
  • lookは移動とはかなり異なります。 プレイヤーは、検査したい方向、アイテム、またはNPCを指定できます。 このコマンドの背後にある重要なロジックは、ステータスに依存する説明について考えるときに考慮されます。

    たとえば、新しい部屋に入ったが、完全に暗く(何も見えない)、それを無視して前に進んだとします。 数部屋後、壁から火のついたトーチを手に取ります。 これで、戻ってその暗い部屋を再検査できます。 トーチを手に取ったので、トーチの内部を見ることができ、そこにあるアイテムやNPCのいずれかと対話することができます。

    これは、ゲーム全体およびプレーヤー固有のステータス属性のセットを維持し、ゲーム作成者がJSONファイル内のステータスに依存する要素のいくつかの説明を指定できるようにすることで実現されます。 すべての説明には、現在のステータスに応じて、デフォルトのテキストと一連の条件付きテキストが用意されています。 後者はオプションです。 必須の唯一のものはデフォルト値です。

    さらに、このコマンドには、 look at room: look aroundための短縮版があります。 これは、プレーヤーが部屋を頻繁に検査しようとするため、入力しやすい速記(またはエイリアス)コマンドを提供することは非常に理にかなっています。
  • pickupコマンドは、ゲームプレイにとって非常に重要な役割を果たします。 このコマンドは、プレイヤーのインベントリまたはハンド(空いている場合)にアイテムを追加する処理を行います。 各アイテムがどこに保存されるかを理解するために、それらの定義には、それが在庫用かプレーヤーの手用かを指定する「宛先」プロパティがあります。 シーンから正常に取得されたものはすべてシーンから削除され、ゲームインスタンスのバージョンのゲームが更新されます。
  • useコマンドを使用すると、インベントリ内のアイテムを使用して環境に影響を与えることができます。 たとえば、部屋の鍵を手に取ると、それを使って別の部屋の鍵のかかったドアを開けることができます。
  • ゲームプレイに関連しない特別なコマンドがありますが、代わりに、現在のゲームIDやプレーヤーの名前などの特定の情報を取得するためのヘルパーコマンドがあります。 このコマンドはgetと呼ばれ、プレーヤーはこのコマンドを使用してゲームエンジンにクエリを実行できます。 例: gameidを取得します
  • 最後に、このバージョンのエンジンに実装されている最後のコマンドは、 attackコマンドです。 これについてはすでに説明しました。 基本的に、ターゲットとそれを攻撃する武器を指定する必要があります。 そうすることで、システムはターゲットの弱点をチェックし、攻撃の出力を判断できるようになります。

クライアントとエンジンの相互作用

上記のエンドポイントの使用方法を理解するために、クライアントになる可能性のあるユーザーが新しいAPIとどのようにやり取りできるかを示します。

ステップ説明
クライアントを登録するまず最初に、クライアントアプリケーションは、他のすべてのエンドポイントにアクセスできるようにAPIキーを要求する必要があります。 そのキーを取得するには、プラットフォームに登録する必要があります。 提供する唯一のパラメーターはアプリの名前です。それだけです。
ゲームを作成するAPIキーを取得した後、最初に行うことは(これがまったく新しいインタラクションであると想定して)、まったく新しいゲームインスタンスを作成することです。 このように考えてください。前回の投稿で作成したJSONファイルにはゲームの定義が含まれていますが、あなたとあなたのパーティーのためだけにそのインスタンスを作成する必要があります(クラスとオブジェクトを同じように考えてください)。 そのインスタンスで好きなように行うことができ、他のパーティに影響を与えることはありません。
ゲームに参加するゲームを作成すると、エンジンからゲームIDが返されます。 その後、そのゲームIDを使用して、一意のユーザー名を使用してインスタンスに参加できます。 ゲームに参加しないとプレイできません。ゲームに参加すると、自分だけのゲーム状態インスタンスも作成されるためです。 これは、プレイしているゲームに関連して、インベントリ、位置、および基本的な統計が保存される場所になります。 同時に複数のゲームをプレイしている可能性があり、それぞれに独立した状態があります。
コマンドを送信する言い換えれば、ゲームをプレイします。 最後のステップは、コマンドの送信を開始することです。 使用可能なコマンドの量はすでにカバーされており、簡単に拡張できます(これについては後で詳しく説明します)。 コマンドを送信するたびに、ゲームはクライアントが新しいゲーム状態を返し、それに応じてビューを更新します。

手を汚しましょう

その情報が次の部分を理解するのに役立つことを期待して、私はできる限り多くの設計を検討しました。それでは、ゲームエンジンの要点を見ていきましょう。

この記事ではコード全体が非常に大きく、すべてが興味深いわけではないため、ここでは完全なコードを示しません。 代わりに、より詳細な部分が必要な場合に備えて、より関連性の高い部分と完全なリポジトリへのリンクを示します。

メインファイル

まず最初に:これはExpressプロジェクトであり、ベースのボイラープレートコードはExpress独自のジェネレーターを使用して生成されたため、 app.jsファイルはおなじみのはずです。 作業を簡素化するために、そのコードに対して行う2つの微調整を確認したいと思います。

まず、次のスニペットを追加して、新しいルートファイルの組み込みを自動化します。

 const requireDir = require("require-dir") const routes = requireDir("./routes") //... Object.keys(routes).forEach( (file) => { let cnt = routes[file] app.use('/' + file, cnt) })

非常にシンプルですが、将来作成する各ルートファイルを手動で要求する必要がなくなります。 ちなみに、 require-dirは、フォルダー内のすべてのファイルの自動要求を処理する単純なモジュールです。 それでおしまい。

私がやりたいもう1つの変更は、エラーハンドラーを少し調整することです。 私は本当にもっと堅牢なものを使い始める必要がありますが、目前のニーズのために、これで作業が完了したように感じます。

 // error handler app.use(function(err, req, res, next) { // render the error page if(typeof err === "string") { err = { status: 500, message: err } } res.status(err.status || 500); let errorObj = { error: true, msg: err.message, errCode: err.status || 500 } if(err.trace) { errorObj.trace = err.trace } res.json(errorObj); });

上記のコードは、処理しなければならない可能性のあるさまざまなタイプのエラーメッセージ(完全なオブジェクト、Javascriptによってスローされた実際のエラーオブジェクト、または他のコンテキストのない単純なエラーメッセージ)を処理します。 このコードはそれをすべて受け取り、標準形式にフォーマットします。

コマンドの処理

これは、拡張が容易でなければならないエンジンのもう1つの側面です。 このようなプロジェクトでは、将来新しいコマンドがポップアップすることを想定するのは完全に理にかなっています。 避けたいことがあれば、3、4か月後に新しいものを追加しようとするときに、ベースコードに変更を加えることはおそらく避けられるでしょう。

コードコメントの量が少ないと、数か月間触れていない(または考えていない)コードを変更する作業が簡単になるため、できるだけ多くの変更を避けることが優先されます。 私たちにとって幸運なことに、これを解決するために実装できるパターンがいくつかあります。 特に、コマンドパターンとファクトリパターンを組み合わせて使用​​しました。

基本的に、各コマンドの動作を、すべてのコマンドのジェネリックコードを含むBaseCommandクラスから継承する単一のクラス内にカプセル化しました。 同時に、クライアントから送信された文字列を取得し、実行する実際のコマンドを返すCommandParserモジュールを追加しました。

実装されたすべてのコマンドに最初の単語に関する実際のコマンド(つまり、「北に移動」、「ナイフを拾う」など)があるため、パーサーは非常に単純です。文字列を分割して最初の部分を取得するだけです。

 const requireDir = require("require-dir") const validCommands = requireDir('./commands') class CommandParser { constructor(command) { this.command = command } normalizeAction(strAct) { strAct = strAct.toLowerCase().split(" ")[0] return strAct } verifyCommand() { if(!this.command) return false if(!this.command.action) return false if(!this.command.context) return false let action = this.normalizeAction(this.command.action) if(validCommands[action]) { return validCommands[action] } return false } parse() { let validCommand = this.verifyCommand() if(validCommand) { let cmdObj = new validCommand(this.command) return cmdObj } else { return false } } }

既存および新規のコマンドクラスを簡単に含めるために、 require-dirモジュールをもう一度使用しています。 フォルダに追加するだけで、システム全体で取得して使用できます。

そうは言っても、これを改善する方法はたくさんあります。 たとえば、コマンドに同義語のサポートを追加できることは素晴らしい機能です(つまり、「北に移動」、「北に移動」、さらには「北に歩く」と言っても同じ意味です)。 これは、このクラスに集中させて、すべてのコマンドに同時に影響を与えることができるものです。

ここでも、コードが多すぎてここに表示できないため、コマンドの詳細については説明しませんが、次のルートコードで、既存の(および将来の)コマンドの処理を一般化する方法を確認できます。

 /** Interaction with a particular scene */ router.post('/:id/:playername/:scene', function(req, res, next) { let command = req.body command.context = { gameId: req.params.id, playername: req.params.playername, } let parser = new CommandParser(command) let commandObj = parser.parse() //return the command instance if(!commandObj) return next({ //error handling status: 400, errorCode: config.get("errorCodes.invalidCommand"), message: "Unknown command" }) commandObj.run((err, result) => { //execute the command if(err) return next(err) res.json(result) }) })

すべてのコマンドはrunメソッドのみを必要とします—それ以外のものは余分であり、内部使用を目的としています。

ソースコード全体を確認することをお勧めします(ダウンロードして、必要に応じて試してみてください)。 このシリーズの次のパートでは、このAPIの実際のクライアント実装と相互作用を紹介します。

まとめ

ここではコードの多くをカバーしていなかったかもしれませんが、最初の設計段階の後でも、この記事がプロジェクトへの取り組み方を示すのに役立つことを願っています。 多くの人が新しいアイデアへの最初の応答としてコーディングを開始しようとしているように感じますが、最終製品を準備する以外に、実際の計画や達成する目標がないため、開発者を落胆させることがあります(そして、それは1日目から取り組むにはマイルストーンとしては大きすぎます。 繰り返しになりますが、これらの記事での私の希望は、大きなプロジェクトで単独で(または小さなグループの一部として)作業するための別の方法を共有することです。

読んで楽しんでいただけたでしょうか! どんな種類の提案や推奨事項も下にコメントを残してください。あなたの考えを読みたいと思います。もしあなたがあなた自身のクライアントサイドコードでAPIのテストを始めたいのなら。

次はまた会いましょう!

このシリーズの他の部分

  • パート1:はじめに
  • パート3:ターミナルクライアントの作成
  • パート4:ゲームにチャットを追加する