Node.jsでのマルチプレイヤーテキストアドベンチャーエンジンの作成:ターミナルクライアントの作成(パート3)
公開: 2022-03-10最初に、このようなプロジェクトを定義する方法を示し、アーキテクチャの基本とゲームエンジンの背後にあるメカニズムを説明しました。 次に、エンジンの基本的な実装、つまりJSONで定義された世界をトラバースできる基本的なRESTAPIを紹介しました。
今日は、Node.js以外を使用せずに、API用の昔ながらのテキストクライアントを作成する方法を紹介します。
このシリーズの他の部分
- パート1:はじめに
- パート2:ゲームエンジンサーバーの設計
- パート4:ゲームにチャットを追加する
オリジナルデザインのレビュー
UIの基本的なワイヤーフレームを最初に提案したとき、画面に4つのセクションを提案しました。
理論的には正しいように見えますが、ゲームコマンドの送信とテキストメッセージの送信を切り替えるのは面倒なことであるという事実を見逃しました。そのため、プレーヤーを手動で切り替える代わりに、コマンドパーサーで確認できるかどうかを確認します。ゲームや友達とコミュニケーションをとろうとしています。
したがって、画面に4つのセクションを表示する代わりに、次の3つのセクションを表示します。
これは、最終的なゲームクライアントの実際のスクリーンショットです。 左側にゲーム画面、右側にチャットが表示され、下部に1つの共通の入力ボックスがあります。 使用しているモジュールを使用すると、色といくつかの基本的な効果をカスタマイズできます。 このコードをGithubから複製して、ルックアンドフィールでやりたいことを実行できます。
ただし、注意点が1つあります。上のスクリーンショットは、チャットがアプリケーションの一部として機能していることを示していますが、この記事では、プロジェクトの設定と、動的なテキストUIベースのアプリケーションを作成できるフレームワークの定義に焦点を当てます。 このシリーズの次の最後の章では、チャットサポートの追加に焦点を当てます。
必要なツール
Node.jsを使用してCLIツールを作成できるライブラリはたくさんありますが、テキストベースのUIを追加することは、飼いならすのとはまったく異なる獣です。 特に、私が望んでいたことを正確に実行できるライブラリを1つだけ見つけることができました。それはBlessedです。
このライブラリは非常に強力で、このプロジェクトでは使用しない多くの機能(シャドウのキャスト、ドラッグアンドドロップなど)を提供します。 基本的に、Node.jsバインディングを持たないncursesライブラリ(開発者がテキストベースのUIを作成できるCライブラリ)全体を再実装し、JavaScriptで直接再実装します。 したがって、必要に応じて、その内部コードを非常によくチェックできます(絶対に必要な場合を除いて、お勧めしません)。
Blessedのドキュメントは非常に広範ですが、主に提供される各メソッドに関する個別の詳細で構成されており(実際にこれらのメソッドを一緒に使用する方法を説明するチュートリアルがあるのではなく)、どこにも例がないため、掘り下げるのは難しいかもしれません。特定のメソッドがどのように機能するかを理解する必要がある場合。 そうは言っても、一度理解すれば、すべてが同じように機能します。これは、すべてのライブラリや言語(私が見ているのは、PHP)の構文が一貫しているわけではないため、大きなプラスです。
しかし、ドキュメントはさておき。 このライブラリの大きな利点は、JSONオプションに基づいて機能することです。 たとえば、画面の右上隅にボックスを描画する場合は、次のようにします。
var box = blessed.box({ top: '0', right: '0', width: '50%', height: '50%', content: 'Hello {bold}world{/bold}!', tags: true, border: { type: 'line' }, style: { fg: 'white', bg: 'magenta', border: { fg: '#f0f0f0' }, hover: { bg: 'green' } } });
ご想像のとおり、ボックスの他の側面(サイズなど)もそこで定義されます。これは、ホバーイベントの場合でも、端末のサイズ、境界線のタイプ、色に基づいて完全に動的にすることができます。 ある時点でフロントエンド開発を行った場合、2つの間に多くの重複があります。
ここで私が言いたいのは、ボックスの表現に関するすべてが、 box
メソッドに渡されるJSONオブジェクトを介して構成されているということです。 そのコンテンツを構成ファイルに簡単に抽出し、それを読み取って画面に描画する要素を決定できるビジネスロジックを作成できるので、私にとっては完璧です。 最も重要なことは、描画された後の外観を垣間見るのに役立つことです。
これは、このモジュールのUIの側面全体のベースになります(これについては後ほど詳しく説明します)。
モジュールのアーキテクチャ
このモジュールの主なアーキテクチャは、これから紹介するUIウィジェットに完全に依存しています。 これらのウィジェットのグループは画面と見なされ、これらの画面はすべて単一のJSONファイル( /config
フォルダー内にあります)で定義されます。
このファイルには250行を超える行があるため、ここに表示しても意味がありません。 オンラインでファイル全体を見ることができますが、その中の小さなスニペットは次のようになります。
"screens": { "main-options": { "file": "./main-options.js", "elements": { "username-request": { "type": "input-prompt", "params": { "position": { "top": "0%", "left": "0%", "width": "100%", "height": "25%" }, "content": "Input your username: ", "inputOnFocus": true, "border": { "type": "line" }, "style": { "fg": "white", "bg": "blue", "border": { "fg": "#f0f0f0" }, "hover": { "bg": "green" } } } }, "options": { "type": "window", "params": { "position": { "top": "25%", "left": "0%", "width": "100%", "height": "50%" }, "content": "Please select an option: \n1. Join an existing game.\n2. Create a new game", "border": { "type": "line" }, "style": { //... } } }, "input": { "type": "input", "handlerPath": "../lib/main-options-handler", //... } } }
「screens」要素には、アプリケーション内の画面のリストが含まれます。 各画面にはウィジェットのリストが含まれ(これについては後で説明します)、すべてのウィジェットには、blesses固有の定義と関連するハンドラーファイル(該当する場合)があります。
すべての「params」要素(特定のウィジェット内)が、前に見たメソッドで期待される実際のパラメーターのセットをどのように表すかを確認できます。 そこで定義されている残りのキーは、レンダリングするウィジェットのタイプとその動作に関するコンテキストを提供するのに役立ちます。
いくつかの興味深い点:
スクリーンハンドラー
すべてのscreen要素には、その画面に関連付けられたコードを参照するfileプロパティがあります。 このコードは、 init
メソッドが必要なオブジェクトに他なりません(その特定の画面の初期化ロジックはその内部で行われます)。 特に、メインUIエンジンは、すべての画面のinit
メソッドを呼び出します。このメソッドは、必要なロジックの初期化(つまり、入力ボックスイベントの設定)を担当する必要があります。
以下はメイン画面のコードです。アプリケーションは、プレーヤーに、新しいゲームを開始するか、既存のゲームに参加するかを選択するオプションを選択するように要求します。
const logger = require("../utils/logger") module.exports = { init: function(elements, UI) { this.elements = elements this.UI = UI this. this.setInput() }, moveToIDRequest: function(handler) { return this.UI.loadScreen('id-requests', (err, ) => { }) }, createNewGame: function(handler) { handler.createNewGame(this.UI.gamestate.APIKEY, (err, gameData) => { this.UI.gamestate.gameID = gameData._id handler.joinGame(this.UI.gamestate, (err) => { return this.UI.loadScreen('main-ui', { flashmessage: "You've joined game " + this.UI.gamestate.gameID + " successfully" }, (err, ) => { }) }) }) }, setInput: function() { let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim() usernameRequest.setValue(question) this.UI.renderScreen() let validOptions = { 1: this.moveToIDRequest.bind(this), 2: this.createNewGame.bind(this) } usernameRequest.on('submit', (username) => { logger.info("Username:" +username) logger.info("Playername: " + username.replace(question, '')) this.UI.gamestate.playername = username.replace(question, '') input.focus() input.on('submit', (data) => { let command = input.getValue() if(!validOptions[+command]) { this.UI.setUpAlert("Invalid option: " + command) return this.UI.renderScreen() } return validOptions[+command](handler) }) }) return input } }
ご覧のとおり、 init
メソッドはsetupInput
メソッドを呼び出します。このメソッドは、基本的に、ユーザー入力を処理するための適切なコールバックを構成します。 そのコールバックは、ユーザーの入力(1または2)に基づいて何をするかを決定するロジックを保持します。
ウィジェットハンドラー
一部のウィジェット(通常は入力ウィジェット)には、その特定のコンポーネントの背後にあるロジックを含むファイルを参照するhandlerPath
プロパティがあります。 これは、前のスクリーンハンドラーと同じではありません。 これらはUIコンポーネントをそれほど気にしません。 代わりに、UIと、外部サービス(ゲームエンジンのAPIなど)とのやり取りに使用しているライブラリとの間のグルーロジックを処理します。
ウィジェットタイプ
ウィジェットのJSON定義へのもう1つのマイナーな追加は、ウィジェットのタイプです。 彼らのためにBlessedが定義した名前を使用する代わりに、私は彼らの行動に関してより多くの小刻みに動く余地を与えるために新しい名前を作成しています。 結局のところ、ウィンドウウィジェットは必ずしも「情報を表示するだけ」ではない場合や、入力ボックスが常に同じように機能するとは限らない場合があります。
これはほとんど先制的な動きであり、将来必要になった場合にその能力を確保するためのものですが、これから説明するように、とにかく多くの異なるタイプのコンポーネントを使用していません。
マルチスクリーン
メイン画面は上のスクリーンショットで示したものですが、ゲームでは、プレーヤー名や、まったく新しいゲームセッションを作成するか、既存のゲームセッションに参加するかなどを要求するために、他のいくつかの画面が必要です。 私がそれを処理した方法は、同じJSONファイルでこれらすべての画面を定義することでした。 また、ある画面から次の画面に移動するには、画面ハンドラーファイル内のロジックを使用します。
これは、次のコード行を使用するだけで実行できます。
this.UI.loadScreen('main-ui', (err ) => { if(err) this.UI.setUpAlert(err) })
UIプロパティの詳細については後ほど説明しますが、このloadScreen
メソッドを使用して画面を再レンダリングし、パラメーターとして渡された文字列を使用してJSONファイルから適切なコンポーネントを選択しています。 非常に簡単です。
コードサンプル
この記事の肉とジャガイモをチェックする時が来ました:コードサンプル。 その中の小さな宝石であると私が思うものを強調するつもりですが、いつでもリポジトリで直接完全なソースコードを見ることができます。
構成ファイルを使用したUIの自動生成
これについてはすでに説明しましたが、このジェネレーターの背後にある詳細を調べる価値があると思います。 その背後にある要点( /ui
フォルダー内のファイルindex.js )は、Blessedオブジェクトのラッパーであるということです。 そして、その中で最も興味深いメソッドは、 loadScreen
メソッドです。
このメソッドは、1つの特定の画面の構成を(構成モジュールを介して)取得し、そのコンテンツを調べて、各要素のタイプに基づいて適切なウィジェットを生成しようとします。
loadScreen: function(sname, extras, done) { if(typeof extras == "function") { done = extras } let screen = config.get('screens.' + sname) let screenElems = {} if(this.screenElements.length > 0) { //remove previous screen this.screenElements.map( e => e.detach()) this.screen.realloc() } Object.keys(screen.elements).forEach( eName => { let elemObj = null let element = screen.elements[eName] if(element.type == 'window') { elemObj = this.setUpWindow(element) } if(element.type == 'input') { elemObj = this.setUpInputBox(element) } if(element.type == 'input-prompt') { elemObj = this.setUpInputBox(element) } screenElems[eName] = { meta: element, obj: elemObj } }) if(typeof extras === 'object' && extras.flashmessage) { this.setUpAlert(extras.flashmessage) } this.renderScreen() let logicPath = require(screen.file) logicPath.init(screenElems, this) done() },
ご覧のとおり、コードは少し長いですが、その背後にあるロジックは単純です。
- 現在の特定の画面の構成をロードします。
- 既存のウィジェットをクリーンアップします。
- すべてのウィジェットを調べてインスタンス化します。
- 追加のアラートがフラッシュメッセージとして渡された場合(これは基本的に、次の更新まで画面に表示されるメッセージを設定するWeb Devから盗んだ概念です)。
- 実際の画面をレンダリングします。
- そして最後に、画面ハンドラーを要求し、その「init」メソッドを実行します。
それでおしまい! 残りのメソッドを確認できます。これらのメソッドは、主に個々のウィジェットとそのレンダリング方法に関連しています。
UIとビジネスロジック間の通信
大規模ではありますが、UI、バックエンド、チャットサーバーはすべて、ある程度階層化されたベースの通信を備えています。 フロントエンド自体には、純粋なUI要素がこの特定のプロジェクト内のコアロジックを表す一連の関数と相互作用する、少なくとも2層の内部アーキテクチャが必要です。
次の図は、構築しているテキストクライアントの内部アーキテクチャを示しています。
もう少し説明させてください。 上で述べたように、 loadScreenMethod
はウィジェットのUIプレゼンテーションを作成します(これらはBlessedオブジェクトです)。 ただし、これらは、基本的なイベント(入力ボックスのonSubmit
など)を設定する画面ロジックオブジェクトの一部として含まれています。
実例を挙げさせてください。 UIクライアントを起動したときに最初に表示される画面は次のとおりです。
この画面には3つのセクションがあります。
- ユーザー名のリクエスト、
- メニューオプション/情報、
- メニューオプションの入力画面。
基本的に、私たちがやりたいのは、ユーザー名を要求してから、2つのオプションのいずれかを選択するように依頼することです(新しいゲームを開始するか、既存のゲームに参加するかのいずれか)。
それを処理するコードは次のとおりです。
module.exports = { init: function(elements, UI) { this.elements = elements this.UI = UI this. this.setInput() }, moveToIDRequest: function(handler) { return this.UI.loadScreen('id-requests', (err, ) => { }) }, createNewGame: function(handler) { handler.createNewGame(this.UI.gamestate.APIKEY, (err, gameData) => { this.UI.gamestate.gameID = gameData._id handler.joinGame(this.UI.gamestate, (err) => { return this.UI.loadScreen('main-ui', { flashmessage: "You've joined game " + this.UI.gamestate.gameID + " successfully" }, (err, ) => { }) }) }) }, setInput: function() { let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim() usernameRequest.setValue(question) this.UI.renderScreen() let validOptions = { 1: this.moveToIDRequest.bind(this), 2: this.createNewGame.bind(this) } usernameRequest.on('submit', (username) => { logger.info("Username:" +username) logger.info("Playername: " + username.replace(question, '')) this.UI.gamestate.playername = username.replace(question, '') input.focus() input.on('submit', (data) => { let command = input.getValue() if(!validOptions[+command]) { this.UI.setUpAlert("Invalid option: " + command) return this.UI.renderScreen() } return validOptions[+command](handler) }) }) return input } }
私はそれがたくさんのコードであることを知っていますが、 init
メソッドに焦点を当てるだけです。 最後に行うことは、適切なイベントを適切な入力ボックスに追加する処理を行うsetInput
を呼び出すことです。
したがって、これらの行で:
let handler = require(this.elements["input"].meta.handlerPath) let input = this.elements["input"].obj let usernameRequest = this.elements['username-request'].obj let usernameRequestMeta = this.elements['username-request'].meta let question = usernameRequestMeta.params.content.trim()
後でsubmit
イベントを設定できるように、Blessedオブジェクトにアクセスしてそれらの参照を取得しています。 したがって、ユーザー名を送信した後、フォーカスを2番目の入力ボックスに切り替えます(文字通りinput.focus()
を使用)。
メニューから選択するオプションに応じて、次のいずれかのメソッドを呼び出します。
-
createNewGame
:関連付けられたハンドラーと対話して新しいゲームを作成します。 -
moveToIDRequest
:ゲームIDの参加要求を担当する次の画面をレンダリングします。
ゲームエンジンとの通信
最後になりましたが(上記の例に従って)、2を押すと、メソッドcreateNewGame
createNewGame
使用し、次にjoinGame
(作成直後にゲームに参加する)を使用することに気付くでしょう。
これらの方法は両方とも、ゲームエンジンのAPIとの相互作用を単純化することを目的としています。 この画面のハンドラーのコードは次のとおりです。
const request = require("request"), config = require("config"), apiClient = require("./apiClient") let API = config.get("api") module.exports = { joinGame: function(apikey, gameId, cb) { apiClient.joinGame(apikey, gameId, cb) }, createNewGame: function(apikey, cb) { request.post(API.url + API.endpoints.games + "?apikey=" + apikey, { //creating game body: { cartridgeid: config.get("app.game.cartdrigename") }, json: true }, (err, resp, body) => { cb(null, body) }) } }
この動作を処理する2つの異なる方法があります。 最初のメソッドは、実際にはapiClient
クラスを使用します。このクラスも、GameEngineとの対話をさらに別の抽象化レイヤーにラップします。
ただし、2番目の方法は、適切なペイロードを使用して正しいURLにPOSTリクエストを送信することにより、アクションを直接実行します。 その後、特別なことは何も行われません。 応答の本文をUIロジックに送り返すだけです。
注:このクライアントのソースコードの完全版に興味がある場合は、ここで確認できます。
最後の言葉
これは、テキストアドベンチャーのテキストベースのクライアント向けです。 私がカバーした:
- クライアントアプリケーションを構築する方法。
- プレゼンテーション層を作成するためのコアテクノロジーとしてBlessedをどのように使用したか。
- 複雑なクライアントからのバックエンドサービスとの相互作用を構築する方法。
- そしてうまくいけば、完全なリポジトリが利用可能になります。
また、UIは元のバージョンとまったく同じようには見えないかもしれませんが、その目的は果たしています。 うまくいけば、この記事があなたにそのような努力をどのように設計するかについての考えをあなたに与えて、あなたが将来それをあなた自身のために試す傾向があったことを願っています。 Blessedは間違いなく非常に強力なツールですが、その使用方法とドキュメント内を移動する方法を学びながら、忍耐力を持っている必要があります。
次の最後のパートでは、バックエンドとこのテキストクライアントの両方にチャットサーバーを追加した方法について説明します。
次はまた会いましょう!
このシリーズの他の部分
- パート1:はじめに
- パート2:ゲームエンジンサーバーの設計
- パート4:ゲームにチャットを追加する