在 Node.js 中編寫多人文本冒險引擎:遊戲引擎服務器設計(第 2 部分)

已發表: 2022-03-10
快速總結↬歡迎來到本系列的第二部分。 第一部分,我們介紹了基於 Node.js 的平台和客戶端應用程序的架構,這將使人們能夠定義和玩自己的文本冒險作為一個群體。 這一次,我們將介紹 Fernando 上次定義的模塊之一(遊戲引擎)的創建,還將關注設計過程,以便在您開始編寫代碼之前了解需要發生的事情自己的愛好項目。

經過仔細考慮和模塊的實際實現,我在設計階段所做的一些定義不得不改變。 對於曾經與渴望獲得理想產品但需要開發團隊克制的熱切客戶合作過的任何人來說,這應該是一個熟悉的場景。

一旦功能被實施和測試,您的團隊將開始注意到某些特徵可能與原始計劃不同,這沒關係。 只需通知、調整併繼續。 因此,事不宜遲,請允許我先解釋一下與原計劃相比有何變化。

本系列的其他部分

  • 第 1 部分:簡介
  • 第 3 部分:創建終端客戶端
  • 第 4 部分:將聊天添加到我們的遊戲中

戰鬥機制

這可能是與原計劃相比最大的變化。 我知道我說過我將採用 D&D 風格的實施方式,其中涉及的每個 PC 和 NPC 都將獲得一個主動值,然後,我們將進行回合製戰鬥。 這是一個好主意,但是在基於 REST 的服務上實現它有點複雜,因為您無法從服務器端啟動通信,也無法維護調用之間的狀態。

因此,相反,我將利用 REST 的簡化機制並使用它來簡化我們的戰鬥機制。 實施的版本將基於玩家而不是基於隊伍,並允許玩家攻擊 NPC(非玩家角色)。 如果他們的攻擊成功,則 NPC 將被殺死,否則他們將通過傷害或殺死玩家進行反擊。

攻擊是成功還是失敗將取決於使用的​​武器類型和 NPC 可能具有的弱點。 所以基本上,如果你試圖殺死的怪物對你的武器很弱,它就會死。 否則,它不會受到影響,而且——很可能——非常生氣。

觸發器

如果您密切關注我之前文章中的 JSON 遊戲定義,您可能已經註意到在場景項目中找到的觸發器定義。 一個特定的涉及更新遊戲狀態( statusUpdate )。 在實施過程中,我意識到將其用作切換開關提供了有限的自由度。 你看,在它的實現方式上(從慣用的角度來看),你可以設置一個狀態,但取消設置它不是一個選項。 因此,我用兩個新的觸發效果替換了這個觸發效果: addStatusremoveStatus 。 這些將允許您準確定義這些影響何時發生——如果有的話。 我覺得這更容易理解和推理。

這意味著觸發器現在看起來像這樣:

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

拿起物品時,我們正在設置狀態,而放下物品時,我們正在移除它。 這樣,擁有多個遊戲級別的狀態指示器是完全可能的並且易於管理。

跳躍後更多! 繼續往下看↓

實施

有了這些更新,我們就可以開始介紹實際的實現了。 從架構的角度來看,沒有任何改變。 我們仍在構建一個包含主遊戲引擎邏輯的 REST API。

技術棧

對於這個特定的項目,我將使用的模塊如下:

模塊描述
Express.js 顯然,我將使用 Express 作為整個引擎的基礎。
溫斯頓與日誌記錄有關的一切都將由 Winston 處理。
配置每個常量和環境相關變量都將由 config.js 模塊處理,這大大簡化了訪問它們的任務。
貓鼬這將是我們的 ORM。 我將使用 Mongoose 模型對所有資源進行建模,並使用它直接與數據庫交互。
uuid 我們需要生成一些唯一的 ID——這個模塊將幫助我們完成這項任務。

至於 Node.js 之外使用的其他技術,我們有MongoDBRedis 。 由於缺少所需的架構,我喜歡使用 Mongo。 這個簡單的事實讓我可以考慮我的代碼和數據格式,而不必擔心更新我的表結構、模式遷移或衝突的數據類型。

關於 Redis,我傾向於在我的項目中盡可能多地使用它作為支持系統,這種情況也不例外。 我會將 Redis 用於所有可以被視為易失性信息的信息,例如黨員編號、命令請求以及其他足夠小且易失性足以不值得永久存儲的數據類型。

我還將使用 Redis 的密鑰過期功能來自動管理流程的某些方面(稍後會詳細介紹)。

API 定義

在進入客戶端-服務器交互和數據流定義之前,我想回顧一下為此 API 定義的端點。 它們並不多,主要是我們需要遵守第 1 部分中描述的主要特性:

特徵描述
加入遊戲玩家可以通過指定遊戲的 ID 來加入遊戲。
創建一個新遊戲玩家也可以創建一個新的遊戲實例。 引擎應該返回一個 ID,以便其他人可以使用它來加入。
返回場景此功能應返回聚會所在的當前場景。 基本上,它將返回描述以及所有相關信息(可能的操作、其中的對像等)。
與場景互動這將是最複雜的之一,因為它將接受來自客戶端的命令並執行該操作——例如移動、推送、獲取、查看、讀取等等。
檢查庫存雖然這是一種與遊戲交互的方式,但它並不直接與場景相關。 因此,檢查每個玩家的庫存將被視為不同的操作。
註冊客戶端應用程序上述操作需要有效的客戶端才能執行。 此端點將驗證客戶端應用程序並返回一個客戶端 ID,該 ID 將用於後續請求的身份驗證。

上面的列表轉換為以下端點列表:

動詞端點描述
郵政/clients 客戶端應用程序將需要使用此端點獲取客戶端 ID 密鑰。
郵政/games 客戶端應用程序使用此端點創建新的遊戲實例。
郵政/games/:id 創建遊戲後,此端點將允許黨員加入並開始遊戲。
得到/games/:id/:playername 此端點將返回特定玩家的當前遊戲狀態。
郵政/games/:id/:playername/commands 最後,使用這個端點,客戶端應用程序將能夠提交命令(換句話說,這個端點將用於播放)。

讓我更詳細地了解我在上一個列表中描述的一些概念。

客戶端應用

客戶端應用程序需要註冊到系統才能開始使用它。 所有端點(除了列表中的第一個端點)都是安全的,並且需要一個有效的應用程序密鑰才能與請求一起發送。 為了獲得該密鑰,客戶端應用程序需要簡單地請求一個。 一旦提供,它們將持續使用,或者將在一個月未使用後過期。 這種行為是通過將密鑰存儲在 Redis 中並為其設置一個月長的 TTL 來控制的。

遊戲實例

創建一個新遊戲基本上意味著創建一個特定遊戲的新實例。 這個新實例將包含所有場景及其內容的副本。 對遊戲所做的任何修改只會影響該派對。 這樣,許多小組可以以自己的個人方式玩相同的遊戲。

玩家的遊戲狀態

這與前一個類似,但對每個玩家都是獨一無二的。 當遊戲實例保存整個隊伍的遊戲狀態時,玩家的遊戲狀態保存一個特定玩家的當前狀態。 主要保存庫存、位置、當前場景和HP(健康點)。

播放器命令

一旦一切都設置好並且客戶端應用程序已經註冊並加入了遊戲,它就可以開始發送命令了。 該版本引擎中實現的命令包括: movelookpickupattack

  • move命令將允許您遍歷地圖。 您將能夠指定要移動的方向,引擎會讓您知道結果。 如果您快速瀏覽第 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文件。 我只想回顧一下我喜歡在該代碼上做的兩個調整,以簡化我的工作。

首先,我添加以下代碼片段來自動包含新路由文件:

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

這真的很簡單,但它消除了手動要求您將來創建的每個路由文件的需要。 順便說一句, require-dir是一個簡單的模塊,它負責自動請求文件夾中的每個文件。 而已。

我喜歡做的另一個改變是稍微調整我的錯誤處理程序。 我真的應該開始使用更強大的東西,但是對於手頭的需求,我覺得這樣可以完成工作:

 // 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 拋出的實際錯誤對像或沒有任何其他上下文的簡單錯誤消息。 此代碼將全部採用並將其格式化為標準格式。

處理命令

這是引擎必須易於擴展的另一個方面。 在像這樣的項目中,假設將來會彈出新命令是完全有道理的。 如果您想避免某些事情,那麼這可能是避免在未來三四個月嘗試添加新內容時對基本代碼進行更改。

再多的代碼註釋都不會使修改幾個月來沒有接觸(甚至沒有考慮過)的代碼的任務變得容易,因此首要任務是盡可能避免更改。 幸運的是,我們可以實現一些模式來解決這個問題。 特別是,我混合使用了命令模式和工廠模式。

我基本上將每個命令的行為封裝在一個類中,該類繼承自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 的實際客戶端實現和交互。

結束的想法

我可能沒有在這裡介紹很多我的代碼,但我仍然希望這篇文章有助於向您展示我如何處理項目——即使在最初的設計階段之後。 我覺得很多人試圖開始編碼作為他們對新想法的第一反應,有時最終會讓開發人員感到沮喪,因為沒有真正的計劃設定或任何目標要實現 - 除了準備好最終產品(從第一天開始,這是一個太大的里程碑)。 再說一次,我希望通過這些文章分享一種不同的方式來單獨(或作為小組的一部分)進行大型項目。

我希望你喜歡閱讀! 如果您有任何類型的建議或建議,請隨時在下面發表評論,如果您渴望開始使用自己的客戶端代碼測試 API,我很樂意閱讀您的想法。

下一期見!

本系列的其他部分

  • 第 1 部分:簡介
  • 第 3 部分:創建終端客戶端
  • 第 4 部分:將聊天添加到我們的遊戲中