用lua开发游戏服务端时,往往会将lua表作为玩家数据存入资料库中,但随著游戏的版本的迭代,往往需要添加、删除存储表的栏位。如果不能够自动的处理数据版本,需要在逻辑层去判断某个栏位是否存在,会增加游戏逻辑开发人员的工作量,也容易出现因为忘记判断某个栏位是否存在而导致的bug。于是我们编写一个名为「表结构描述」的模块,让它自动处理数据版本。

为什么需要版本控制

比如在一款坦克游戏中,玩家身上的数据可能如下所示。玩家的id是123,名字叫「坦克战神」,它拥有两辆坦克,其中一辆是id为101的坦克,另外一辆是id为102的坦克。

playerdata = {
uuid = 123,
name = "坦克战神",
tanks = {
[101] = { id = 101},
[102] = { id = 102},
}
}

正常情况下,只需要将playerdata序列化然后存储到资料库中。然而对于经常迭代的游戏,因为经常需要新增或删除存储栏位,可能会导致逻辑编写的困难。比如在某个版本中需要给坦克增加一个等级的属性,玩家的存储结构变成如下的lua表。

playerdata = {
uuid = 123,
name = "坦克战神",
tanks = {
[101] = { id = 101, level = 1},
[102] = { id = 102, level = 2},
}
}

因为需要新增level栏位,而老玩家的游戏数据里并没有这个栏位,于是在游戏逻辑里可能需要下面这么写。

--以获取坦克攻击力的函数为例
--假设坦克的攻击力和等级有关
function get_tank_att(id)
local tank = playerdata.tanks[id]

if not tank.level then
tank.level = 1
end

return 100+tank.level*10
end

意味著在使用到level的逻辑里,都需要去判断level存不存在,如果不存在就赋予初值。

如果游戏存储了很多数据,而且版本更迭较快,我们将很难弄成哪些栏位需要判断是否存在,哪些栏位不需要判断,每一次写逻辑都要多考虑存不存在的问题,很麻烦。

希望的结果

我们希望随著游戏更迭,如果需要某个栏位,在载入数据时就能够自动给该栏位赋予初值,而不需要手动去判断值是否存在。在上面的例子中,如果框架层就能够自动给level赋予初值,逻辑层将能够减少一次判断,减轻逻辑开发人员的压力。

表结构描述

为了达到上述目的,需要一个表结构描述的表,描述存储的数据到底包含了哪些数据,这些数据的初始值是什么。我们规定一种如下的表结构,它能够支持string、number、array(数组)、dict字典四种类型的数据,并且支持嵌套。该表与常规的lua表没有区别,栏位的值代表默认值,如果表中有key为」_array」的栏位,代表该表里的元素都是」_array」栏位所描述的数组;如果表中有key为」_dict」的栏位,代表该表里的元素都是」_dict」栏位所描述的字典。

以第一个版本的playerdata为例,它的表描述结构如下。其中的「["_dict"] = { id = -1}」代表tanks是一个字典,每个字典的元素包含id一个栏位。

desc = {
uuid = -1,
name = "",
tanks = {
["_dict"] = { id = -1},
}
}

以第二个版本的playerdata为例,它的表描述结构如下。其中的「["_dict"] = { id = -1, level = 1}」代表tanks是一个字典,每个字典的元素包含id和level两个栏位,而且level的默认值为1。

desc = {
uuid = -1,
name = "",
tanks = {
["_dict"] = { id = -1, level = 1},
}
}

版本匹对

定义一个版本匹对的方法commit(desc, inst),它带有两个参数,第一个参数代表描述表,第二个参数代表存储表。以上述坦克数据为例,在经过匹对方法后,会自动给坦克数据增加level栏位,而且默认值是1。

调用方法举例:

local desc = {
tanks = {
["_dict"] = { id = -1, level = 1},
}
}

local inst = {
name = "hehe",
tanks = {
[101] = { id = 101},
[102] = { id = 102},
}
}

local n = M.commit(desc, inst)

--返回结果为
n = {
tanks = {
[101] = { id = 101, level = 1},
[102] = { id = 102, level = 1},
}
}

处理版本匹对的commit方法,它遍历描述结构表,对不同的数据类型做判断。如果描述表中存在实例表的栏位,则复制实例表的栏位。如果是数组或字典类型,将对实例表的每一项和描述表的对应栏位做匹配,只返回匹配的结果或者默认值。

local M = {}
local DICTKEY = "_dict"
local ARRAYKEY = "_array"

--desc为类的结构描述
--inst为实例
function M.commit(desc, inst)
--number & string
if type(desc) == "number" or type(desc) == "string" then
if type(desc) == type(inst) then
return inst
else
return desc
end
--struct
elseif type(desc) == "table" and not desc[DICTKEY] and not desc[ARRAYKEY] then
local o = {}
if type(inst) ~= "table" then
return nil
end
for k, v in pairs(desc) do
o[k] = M.commit(desc[k], inst[k])
end
return o
--dict
elseif type(desc) == "table" and desc[DICTKEY] then
local o = {}
if type(inst) ~= "table" then
return o
end
local element_desc = desc[DICTKEY]
for k, v in pairs(inst) do
o[k] = M.commit(element_desc, inst[k])
end
return o
--array
elseif type(desc) == "table" and desc[ARRAYKEY] then
local o = {}
if type(inst) ~= "table" then
return o
end
local element_desc = desc[ARRAYKEY]
for i, v in ipairs(inst) do
o[i] = M.commit(element_desc, inst[i])
end
return o
end

print("tabledesc error")
return nil
end

return M

有了该版本匹对的方法后,只要先编写描述表,描述表能够清晰的指明哪些数据需要存储,很有意义。然后在读取数据和存储数据时都匹对一次,这样将使存储和读取的数据都符合最新的描述,起到了数据版本升级的功能。

更多做法

本文的方法是基于lua表描述和匹对的数据版本更新方法。除了本文提及的方法外,使用lua面向对象编程、使用protobuf做序列化也能够实现同样的功能。但本文提及的方法最为简单纯粹,不涉及任何第三方库,且直观易懂。

最后还是放个广告吧。学了Unity,能做游戏,可是怎样制作多人网路游戏,怎样解决网路游戏中的卡顿,掉线问题?怎样做到实时的同步?笔者即将出版的《网路游戏实战(第二版)》或许会对大家解决这些问题有所帮助。比起第一版,第二版相隔两年出版,几乎重写全部内容,以制作一款完整的多人坦克对战游戏为例,详细介绍网路游戏的开发过程。透过本书,读者能够掌握Unity网路游戏开发的大部分知识,能够深入了解TCP底层机制,能够亲自搭建一套可重复使用的客户端框架,也能够从框架设计中了解商业游戏的设计思路,循序渐进,结合实例,深入的讲解网路游戏开发所需的知识。


推荐阅读:
相关文章