文章 | 机核 GCORES ( ) • 2024-04-30 11:59
MUD

为什么要做一个 MUD

MMORPG 曾经是中国游戏行业中最火的游戏品类,这一类游戏的开发成本也是巨高无比。但是,早期的 MMORPG,其结构却并不是特别复杂,譬如《梦幻西游》这类网游,在最早期的时候,参考的技术只是 MUD 而已。
关于 MUD,我不想过多的介绍其历史和技术底层,只是想告诉大家,这是一种“瘦客户端”的游戏:
  • 整个虚拟的游戏世界,都运行在服务器上,客户端仅仅是提供玩家对服务器世界的输入、输出功能而已
  • 服务器的内存中,保存着整个虚拟世界的信息,包括场景、角色、物品、战斗等等,随着服务器程序的运行,这个虚拟世界也在产生昼夜和四季的变换
  • 玩家通过输入文字命令,去操作自己在虚拟世界中的角色;服务器也通过文字,把世界中的各种信息输出给玩家。不同的玩家可以在同一个服务器世界中相遇、相交。甚至游戏的开发者,我们称为“巫师”,也是直接进入这个世界后,进行程序开发,就如同在飞行的飞机上改造飞机。
显然,这种完全基于文字的游戏,不可能称为游戏的主流,但是这类游戏依然有它的价值:
  1. 对于失明人士来说,这种游戏几乎是他们唯一可以玩的电子游戏
  2. 作为 MMORPG 这种类型来说,MUD 的服务器端技术是这类游戏技术的起源,具有很大的学习价值
  3. 在 ChatGPT 这类自然语言 AI 流行的今天,这类文字游戏可以利用最新的 AI 技术,发挥出超强的生命力
所以,让我们从头来做一个 MUD 吧!

神说:要有光

开发一个 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 中加入更多好玩的战斗玩法