MMORPG 曾经是中国游戏行业中最火的游戏品类,这一类游戏的开发成本也是巨高无比。但是,早期的 MMORPG,其结构却并不是特别复杂,譬如《梦幻西游》这类网游,在最早期的时候,参考的技术只是 MUD 而已。
关于 MUD,我不想过多的介绍其历史和技术底层,只是想告诉大家,这是一种“瘦客户端”的游戏:
显然,这种完全基于文字的游戏,不可能称为游戏的主流,但是这类游戏依然有它的价值:
所以,让我们从头来做一个 MUD 吧!
开源地址:https://daxiaohan.coding.net/public/luamud/luamud/git/files
开发一个 MUD,几乎等于要构建一个“赛博空间”的虚拟世界。要创造一个世界,最开始需要什么呢?
对于第一个需求,显然一门脚本语言是非常适合的,譬如 Python、JS,但我更喜欢 Lua,因为这门语言的非常纯粹,附带的东西非常少,很适合从零开始。对于第二个需求,则需要设计一种网络服务功能,以及一种文件存档功能,来让玩家“活”在这个虚拟世界中,这两个功能,也是 Lua 语言唯一需要依赖的外部功能。对于网络功能,我使用最基本的 luasocket 这个库;而文件功能,Lua 语言自带的 io 包已经可以胜任了。于是,在有了 Lua 和 luasocket 之后,这个世界可以开始建造了。
对于游戏最基本的功能,那些和游戏世界的描述最不相关,但是必的能力,就好像我们世界中的物理定律的东西,我称为 “MudOS”,它包括以下几个功能:
具体的游戏世界功能,我称为“MudLib”,这部分代码设定了具体不同的游戏的差异,这部分代码使用 MudOS 的功能,来构建各种的玩法。对于一个 MMORPG 来说,往往需要有场景、角色、道具、技能等等。
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
如果让玩家能接入这个世界,需要有两个过程:
对于网络功能,我开发了一个 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
作为文字游戏的“房间”,需要有三个东西:
ToStr()
实现exits
属性实现。这个属性是个 Table,key 是出口方向,value 是连接的场景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"
}
})
一个游戏里面当然需要人,一般包括两类:
因此我也设计了两个类型,一个叫 Char(角色),一个叫 Player。其中 Player “继承”于 Char。对于角色来说,设计了以下几个方法:
Desc()
返回。user_data
,以及专门给玩家单人发送信息的方法 Reply()
。MudLib/char.lua
整个游戏最复杂的部分就是行为部分,这是由一系列的“命令”组成的。一部分是游戏最基本的命令:
基本上可以认为是一个聊天室的功能。这部分能力实现在 MudLib/cmds.lua
当中。
而具体游戏中的额外的命令,则可以通过 MudLib/Cmd/XXX.lua
进行添加,现在包括了:
虽然游戏命令非常少,但是已经可以构造一个基本的,带技能的战斗玩法。
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 目录中的文件,把角色、场景、战斗三个基本要素做了一个实例。后续可以从更多的角度去扩展: