讓我們從頭做一個 MUD 吧!


3樓貓 發佈時間:2024-04-30 15:32:25 作者:韓大 Language

為什麼要做一個 MUD

MMORPG 曾經是中國遊戲行業中最火的遊戲品類,這一類遊戲的開發成本也是巨高無比。但是,早期的 MMORPG,其結構卻並不是特別複雜,譬如《夢幻西遊》這類網遊,在最早期的時候,參考的技術只是 MUD 而已。
關於 MUD,我不想過多的介紹其歷史和技術底層,只是想告訴大家,這是一種“瘦客戶端”的遊戲:
  • 整個虛擬的遊戲世界,都運行在服務器上,客戶端僅僅是提供玩家對服務器世界的輸入、輸出功能而已
  • 服務器的內存中,保存著整個虛擬世界的信息,包括場景、角色、物品、戰鬥等等,隨著服務器程序的運行,這個虛擬世界也在產生晝夜和四季的變換
  • 玩家通過輸入文字命令,去操作自己在虛擬世界中的角色;服務器也通過文字,把世界中的各種信息輸出給玩家。不同的玩家可以在同一個服務器世界中相遇、相交。甚至遊戲的開發者,我們稱為“巫師”,也是直接進入這個世界後,進行程序開發,就如同在飛行的飛機上改造飛機。
顯然,這種完全基於文字的遊戲,不可能稱為遊戲的主流,但是這類遊戲依然有它的價值:
  1. 對於失明人士來說,這種遊戲幾乎是他們唯一可以玩的電子遊戲
  2. 作為 MMORPG 這種類型來說,MUD 的服務器端技術是這類遊戲技術的起源,具有很大的學習價值
  3. 在 ChatGPT 這類自然語言 AI 流行的今天,這類文字遊戲可以利用最新的 AI 技術,發揮出超強的生命力
所以,讓我們從頭來做一個 MUD 吧!
開源地址:https://daxiaohan.coding.net/public/luamud/luamud/git/files 機核試玩地址:https://www.gcores.com/games/125832

神說:要有光

開發一個 MUD,幾乎等於要構建一個“賽博空間”的虛擬世界。要創造一個世界,最開始需要什麼呢?
  1. 首先,要有一門編程語言。這門語言不但構造這個世界,而且還需要能在這個世界中運行,同時用來表達這個世界的信息。
  2. 其次,這個運行在電腦內存中的世界,需要和處於“外界”的玩家聯繫,這種聯繫需要兩個方面功能:玩家進行聯繫、能保存玩家的狀態
對於第一個需求,顯然一門腳本語言是非常適合的,譬如 Python、JS,但我更喜歡 Lua,因為這門語言的非常純粹,附帶的東西非常少,很適合從零開始。對於第二個需求,則需要設計一種網絡服務功能,以及一種文件存檔功能,來讓玩家“活”在這個虛擬世界中,這兩個功能,也是 Lua 語言唯一需要依賴的外部功能。對於網絡功能,我使用最基本的 luasocket 這個庫;而文件功能,Lua 語言自帶的 io 包已經可以勝任了。於是,在有了 Lua 和 luasocket 之後,這個世界可以開始建造了。

世界的結構

對於遊戲最基本的功能,那些和遊戲世界的描述最不相關,但是必的能力,就好像我們世界中的物理定律的東西,我稱為 “MudOS”,它包括以下幾個功能:
  1. 遊戲世界的時間主線:程序入口和主循環,定時器功能
  2. 遊戲世界和玩家的接口:網絡服務器,解析命令數據包
  3. 遊戲世界給玩家的存檔:文件存儲以及數據序列化、反序列化了
  4. 遊戲世界中所有的“對象”模型(Lua 沒有官方的“對象模板”形式的“類”,因此對象的繼承能力需要自己實現)
具體的遊戲世界功能,我稱為“MudLib”,這部分代碼設定了具體不同的遊戲的差異,這部分代碼使用 MudOS 的功能,來構建各種的玩法。對於一個 MMORPG 來說,往往需要有場景、角色、道具、技能等等。
MudLib 與 MudOS 的關係

MudLib 與 MudOS 的關係

世界的時間線
MudOS/main.lua
這個世界有一個叫做“世界心臟(Heart Of World)”的唯一全局對象,所有在遊戲中,會隨著時間變化的對象,都需要通過 Add() 方法把自己加入這個對象;在對象“死去”的時候,用 Del() 方法去掉自己的引用。一旦“加入了”世界心臟後,這些對象的 HartBeat() 心跳方法就會跟隨“世界心臟”定期跳動,所有的對象需要不斷運行的行為,都可以放入自己的心跳方法裡。
--- Timer System HeartOfWorld = { rate = 1, -- beat times per second members = {}, -- all hearts in here last_beat_time = 0 } function HeartOfWorld:Add(heart) ...... end function HeartOfWorld:Del(heart_or_idx) ...... end function HeartOfWorld:Tick()
...... -- Make hearts beating for idx, obj in pairs(self.members) do if obj.HeartBeat ~= nil and type(obj.HeartBeat) == 'function' then obj:HeartBeat(now) end end ...... end

接納玩家

如果讓玩家能接入這個世界,需要有兩個過程:
  1. 監聽網絡,記錄在線的玩家
  2. 處理用戶輸入和給於輸出
對於網絡功能,我開發了一個 TCP 服務器,這個服務器可以 Start() 方法監聽玩家的連接,接收玩家發來的數據;以及用 SendTo() 方法發回消息給玩家。
值得注意的是,LUA 使用的單線程異步的 IO 模型,所以網絡服務需要一個持續性的循環進行驅動。這裡把“世界心臟”的觸發也放到網絡服務的主循環中了。而更好的做法應該是“世界心臟”負責主循環,並且在主循環中操作網絡 IO。
--- A TCP server can be set a recieving handler. TcpServer = { num2client = {}, -- 通過玩家 ID 找到客戶端對象的索引表 client2num = {}, -- 通過客戶端對象找到玩家 ID 的索引表 clients = {}, -- 客戶端列表 conn_count = 0 -- 當前連接總數 } function TcpServer.Start(self, bind_addr, handler) ...... -- 遊戲主循環 -- while true do -- Processing network events ...... -- Processing heartbeat timer
HeartOfWorld:Tick() end end function TcpServer.SendTo(self, client_id, message, no_ret) ...... end function TcpServer.CloseClient(self, client_id) ...... end ... print("Starting TCP server ...") TcpServer:Start({}, handler) ...
由於需要處理玩家的行為,我設計了一個“命令系統”,這個系統存放了所有的“命令”。玩家發來的所有行為數據,“命令系統”都會嘗試解釋成一個“命令”,如果解釋成功,就會去調用對應的“命令方法”。
另外,為了讓“命令方法”更容易編寫,我對已經連接到服務器上的玩家,設計了一個記錄這些玩家對象的在線列表。我以一次“會話”來描述玩家的在線狀態,設計了一個“會話池”來保存所有的在線玩家的對象。命令代碼運行時,可以很方便的獲得在線的所有玩家對象,同時也可以通過 THIS_PLAYER 這個全局對象,來獲得當前發出命令的玩家對象的引用。
-- Sessions pool -- SessionPool = {} ... --- A command system which you can set a command to it. CommandSystem = { cmd_tab = {}, -- 存放所有命令的容器 ...... ProcessCommand = function(self, user_id, command_line) ....... -- 命令行方式解析輸入的文字 string.gsub(command_line, "[^ ]+", function(w) table.insert(cmds, w) end) ...... local cmd_fun = self.cmd_tab[cmd] -- 查找命令 ...... THIS_PLAYER = SessionPool[user_id] -- Shotcut: this_player ...... local ret = cmd_fun(cmds) -- 運行命令 Reply(PROMPT, true) return ret ...... end }

響徹天際

對於連接到這個世界的玩家,必須要有一個手段讓玩家知道這個世界中發生的事情。我設計了一個 Channel 類型來完成這個功能,它負責做對某個範圍的玩家進行網絡廣播。玩家可以被加入到一個或者多個 Channel 中,然後根據世界的邏輯,他們會收到廣播的信息。
--- Broadcast system Channel = { members = {} }
...... function Channel:Join(user_id, member) ... end function Channel:Leave(user_id) ... end function Channel:Say(message, ...) for user_id, member in pairs(self.members) do local ignore = false for i, sender in ipairs { ... } do if member == sender then ignore = true end end if ignore == false then TcpServer:SendTo(user_id, message) end end end

保存玩家數據

玩家存檔的格式,我希望是一段 Lua 源碼,這段源碼記錄了一個 table 對象。——這個功能由 MudOS/serialize.lua 實現。對於玩家的登錄密碼,展示記錄密碼的 md5。不記錄密碼的原文,是為了防止這個遊戲的數據有問題之後,讓玩家的常用密碼也給洩露了。
對於整個玩家記錄的功能,我設計了一個叫 UserData 的“類”,每個玩家的存檔就是一個 UserData 類的對象。這個對象提供了 Save/Load 的方法,這兩個方法會使用 serialize.lua 的代碼,對存檔內容進行解析和編碼。
把內存中的對象數據,保存到文件,或者通過網絡發出去,需要把對象的數據進行某種編碼。這個過程稱為“序列化”,相反的過程則為“反序列化”。這裡的 MudOS/serialize.lua 就是對玩家存檔數據進行“序列化/反序列化”的代碼。
--- Save/Load user data require("MudOS/serialize") local md5 = require("MudOS/md5") ...... UserData = { user_name = nil, pass_token = nil, ....... Load = function(user_name, password) ...... end, Save = function(self) ....... local save_obj_name = 'player_' .. self.user_name io.output(save_file) save(save_obj_name, self) io.close(save_file) return true end, ....... }

盤古開天地

遊戲世界中的具體事物非常繁多複雜,所以我把這些稱為 MudLib,然後設計一個整體加載全部具體事物的腳本 index.lua,這個腳本具體去加載各種“遊戲系統”。真正對於遊戲世界的詳細描述,放在 MudLib 目錄下。
-- Load GameLib level code print("Start to load GameLib ...") require("MudLib/index") -- Start up network procedule ... print("Starting TCP server ...") TcpServer:Start({}, handler) ...
在一個 MMORPG 中,基本玩法的構造,可以分成多個“遊戲系統”,每個系統用一個或幾個 Lua 腳本作為入口
MudLib/index.lua
... print("正在構建空間系統 ...") require("MudLib/space") print("正在構建房間系統 ...") require("MudLib/room") require("MudLib/map") print("正在構建角色系統 ...")
require("MudLib/char") -- TODO 構建“道具系統” print("正在構建戰鬥系統 ...") require("MudLib/combat") print("正在構建命令系統 ...") dofile(MUD_LIB_PATH .. "cmds.lua") ...
至此,這個網絡遊戲世界所需要的最基本功能,已經完全具備了,下一步就需要開始構建真正的遊戲世界了。

空間

空間 Space 是一個可以存放其他物體的物體。在空間中的物體,本身也可以是一個空間。譬如房間裡面有人,人身上能放揹包,揹包裡面還能放東西。
MudLib/space.lua
---代表一個物理空間物體 --@param environment 所處環境 --@param content 內容 SpaceObject = { environment = nil, content = {}, New = function(self, value) ... end, --查找本身包含的內容物 --@param #table key 內容物的屬性名,如果是nil則對比整個內容物體 --@param #table value 要查找的屬性值或者內容物本身 --@param #function fun是找到後的處理函數,形式fun(pos, con_obj) --@return #table 返回fun()的返回值(僅限第一個返回值)數值,或者是找到的對象數組 Search = function(self, key, value, fun) ... end, Leave = function(self) ... end, Put = function(self, env) ... end, Dispose = function(self) ... end } World = SpaceObject:New() -- 所有物理空間存放的位置 World.channel = Channel:New() -- 構建一個世界頻道
最重要的是,用一個全局變量 World 給這個遊戲世界,一個唯一的、全局的空間對象,所有在遊戲中的物理對象,都放在這個對象中。
另外 World.channel 展示了一個遊戲的空間系統,除了要能“放下物體”以外,同時也需要一個廣播頻道,才能讓所有在這個空間中的玩家,獲得空間最新的信息變化。而這個 channel 屬性,是預備用來作為全服廣播對象的。
當我們有了最基礎的“空間”概念,就可以開始構建具體的一個個場景:房間了
MudLib/room.lua
Room = { title = "虛空", desc = "這裡一片白茫茫", exits = {}, --east="xxx", west="yyy", ... channel = World.channel } function Room:New(value) local ret = NewInstance(self, value, SpaceObject) ret:Put(World) ret.channel = Channel:New() return ret end function Room:ToStr() local output = [[ --%s-- %s 這裡的出口: %s 這裡有: %s]] ... return string.format(output, self.title, self.desc, exits_str, content_str) end
作為文字遊戲的“房間”,需要有三個東西:
  1. 這個場景是什麼樣子的,通過 ToStr() 實現
  2. 這個場景和其他什麼地方連通,以便角色可以移動,通過 exits 屬性實現。這個屬性是個 Table,key 是出口方向,value 是連接的場景
  3. 這個場景的廣播頻道,用於讓本場景內的信息可以發送給玩家,通過 channel 實現
對於具體的房間,只要填寫上述 1,2 兩個部分的數據,就可以構建出任何狀態的場景
MudLib/map.lua
BornPoint = Room:New({ title = "出生點", desc = "這裡是一片空地,周圍站著很多剛註冊的新手玩家。", exits = { east = "NewbiePlaza", west = "SmallRoad" } }) NewbiePlaza = Room:New({ title = "新手廣場", desc = "光禿禿的黃土地上,有幾棵小樹。", exits = { west = "BornPoint" } }) SmallRoad = Room:New({ title = "小路", desc = "這條小路荒草蔓延。似乎是通往外界的唯一道路。", exits = { east = "BornPoint" } })

角色

一個遊戲裡面當然需要人,一般包括兩類:
  1. NPC
  2. Player
因此我也設計了兩個類型,一個叫 Char(角色),一個叫 Player。其中 Player “繼承”於 Char。對於角色來說,設計了以下幾個方法:
  • 新建/消失
  • 說話。調用當前場景的 channel 進行廣播。
  • 移動。進入當前場景,並且會廣播進入的動作。
  • 描述。當角色被觀察時,把角色的描述、狀態進行返回。固定描述用 Desc() 返回。
  • 心跳。這是最重要的方法,所有角色存在的“狀態”,都需要在這個方法中描述。這裡實現了最基本的“戰鬥狀態”:只要發現了被標記為“敵人”的角色,就調用“戰鬥系統”發起攻擊。 對於 Player 類型,除了上述的內容以外,還有自己的存檔對象 user_data,以及專門給玩家單人發送信息的方法 Reply()
MudLib/char.lua

行為

整個遊戲最複雜的部分就是行為部分,這是由一系列的“命令”組成的。一部分是遊戲最基本的命令:
  • 登錄
  • 註冊
  • 登出
  • 移動
  • 觀察
  • 說話
基本上可以認為是一個聊天室的功能。這部分能力實現在 MudLib/cmds.lua 當中。
而具體遊戲中的額外的命令,則可以通過 MudLib/Cmd/XXX.lua 進行添加,現在包括了:
  • hp 狀態查看
  • kill 觸發戰鬥
  • skill 使用技能
雖然遊戲命令非常少,但是已經可以構造一個基本的,帶技能的戰鬥玩法。
CommandList.kill = function(cmds) local target = nil local target_id = cmds[2] -- cmds[1]是指令本身,cmds[2]才是參數 if target_id == nil then Reply("你怒氣衝衝的瞪著空氣,不知道要攻擊誰。") return else if target_id == THIS_PLAYER.id then Reply("你狠狠用左腳踢了一下自己的右腳,發現這個行為很傻,於是就停止了。") return end local targets = THIS_PLAYER.environment:Search('id', target_id) if #targets == 0 then Reply(string.format("沒有%s這個東西", target_id)) return elseif targets[1].hp ~= nil and targets[1].hp > 0 then target = targets[1] else Reply("你不能攻擊一個死物。") return end end if target ~= nil then table.insert(THIS_PLAYER.fright_list, target) Reply(string.format("你對著%s大喝一聲:“納命來!”", target.name)) --反擊 table.insert(target.fright_list, THIS_PLAYER) Reply(string.format("%s對你一瞪眼,一跺腳,狠狠道:“竟敢在太歲頭上動土?”", target.name)) if target.user_id ~= nil then target:Reply(string.format("%s向你發起了攻擊!", THIS_PLAYER.name)) end end end
對於命令,只要使用了 CommandList.XXX 就可以定義,其中 XXX 就是命令輸入字符。函數中的 cmds 是一個數組,包含玩家輸入的整個命令行,以空格進行劃分。
最後,說說戰鬥系統
MudLib/combat.lua
整個戰鬥系統,實際上只是一個函數 Combat(),這個函數會隨心跳,不斷被角色所調用。這有點類似一般遊戲引擎中的 Update() 驅動邏輯運算。而戰鬥中的各種技能,都是在這個函數的過程中,根據角色身上的屬性,進行不同的運算。
if a_skill ~= nil then -- 有招攻無招 if d_skill == nil then damage = double_power else --這種複雜判斷其實應該用哈系表查詢,但是if寫法更容易表達內在含義 --tiger>monkey>crane>tiger if a_skill == d_skill then damage = normal_power elseif a_skill == "tiger" then if d_skill == "monkey" then damage = double_power elseif d_skill == "crane" then damage = lease_power end elseif a_skill == "monkey" then if d_skill == "tiger" then damage = lease_power elseif d_skill == "crane" then damage = double_power end elseif a_skill == "crane" then if d_skill == "monkey" then damage = lease_power elseif d_skill == "tiger" then damage = double_power end end end end
這裡設計了三個“技能”,代表三個招式,通過類似錘子剪刀布的方式,影響攻擊計算的結果。

最後

MudLib 目錄中的文件,把角色、場景、戰鬥三個基本要素做了一個實例。後續可以從更多的角度去擴展:
  1. 通過 map.lua 構造更多的地圖
  2. 通過 Cmd/xxx.lua 增加更多用戶可以做的行為,譬如解謎玩法
  3. 通過擴展 Char 類型,增加 NPC
  4. 擴展 Space 類對象,讓世界多一些“道具”
  5. 在 combat.lua 中加入更多好玩的戰鬥玩法

© 2022 3樓貓 下載APP 站點地圖 廣告合作:asmrly666@gmail.com