在几天前(30+天),已经对Sqlite3和pb来制作游戏存档做了讲解。
Unity 通过Sqlite3和lua-protubuf制作游戏存档
今天来讲一下,在Lua中的实用。
我不知道其他独立游戏工作室是如何制作存档的,也不知道他们是用C#还是Lua来做存档。我只是因为项目需要,使用Lua来做对存档做存储和读取。如果有更好的做法,欢迎交流。
接下里是废话,如果不感兴趣,可以直接跳过,直接去看下一个标题。
这个最终做法是经过一周的尝试,改了又改,删了又删,最终找到的一种对我而言,算是比较安全便捷的写法。
一开始的时候,所有都是硬夯上去的。很干脆的对转出来的存档数据进行改写,然后存储。
不过写着写着,想到这样不太好。因为LuaTable是引用类型,要是在某个业务逻辑中不小心改了某一个值又存储了,就会导致整个存档在这里出现问题。
比如说一个装备的等级在展示的时候要显示最高级,按理说代码应该是这样的:
function EquipShowPanel:InitData(equip, levelTextComp)
...
--等级文本组件
self.levelTextComp = levelTextComp
...
--最高等级数值
self.maxLevel = EquipMgr:GetMaxLevel(equip.equipId)
...
end
function EquipShowPanel:SetUIShow()
...
--设置等级本文显示
self.levelTextComp.text = self.maxLevel
...
end
但是可能一个不小心就搞成了这样:
function EquipShowPanel:InitData(equipData, levelTextComp)
...
--等级文本组件
self.levelTextComp = levelTextComp
...
--更新此界面所需数据
equipData.level = EquipMgr:GetMaxLevel(equipData.equipId)
self.equipData = equipData
...
end
function EquipShowPanel:SetUIShow()
...
--设置等级本文显示
self.levelTextComp.text = self.equipData.level
...
end
这个功能是没有问题的,但是实际上这个equipData是直接指向存档的数据的。这就相当于暗改了存档数据。
这个对于不清楚值类型和引用类型的人来说,甚至一些有经验的人来说,都是可能发生的。
因此这个做法不够安全。
因此就要换一个做法。
首先就是先把存档的内容变成只读表,可以保证存档数据的安全性,不会出现暗改的问题。只读表的做法会在下面有提及。
但是新的问题又出现了,只读表因为其做法的原因,不能被pb.encode正确的读取并转为二进制流。
所以只能再通过深拷贝对存档数据(sourceTable)进行拷贝(copyTable)并做只读表。然后把copyTable作为各个Mgr的数据区。当有确定要修改存档数据时,先对存档进行修改,然后再深拷贝重新赋值给Mgr的数据区。管理器再做相关处理。
听起来可能有点乱,让我用简单代码说明一下:
---存档管理类
GameSaveMgr = {
}
--存档数据
local GameSaveData = {
}
--加载存档数据
function GameSaveMgr:LoadGameSaveData()
local saveBytes = CS.GameSave.GetData()
GameSaveData = pb.decode(".Save", saveBytes)
end
--获取存档数据
function GameSaveMgr:GetGameSaveData()
return GameSaveData
end
--保存存档数据
function GameSaveMgr:SaveGameSaveData()
CS.GameSave.SetData(GameSaveData)
end
---初始化数据区
_G.InitMgrData = function()
local copySaveData = table.copy(GameSaveMgr:GetGameSaveData())
local readOnlySaveData = table.readObly(copySaveData)
BagMgr:LoadBagData(readOnlySaveData.bagData)
end
---背包管理器类
BagMgr = {
}
--背包数据区
local BagData = {
}
--加载背包数据
function BagMgr:LoadBagData(bagData)
BagData = bagData
end
--修改数据区道具数量
function BagMgr:SetItemCount(itemId, itemCount)
for _, item in ipairs(BagData) do
if item.item_id == itemId then
item = table.readOnly({
item_id = itemId,
item_count = itemCount.
})
end
end
--通知UI修改显示,当然并不推荐能直接调用BagPanel这样,为了直观才这么写。
BagPanel:SetItemCountShow(itemId, itemCount)
end
---背包数据存储类
BagDBSet = {
}
--修改存档背包道具数量
function BagDBSet:SetItemCount(itemId, itemCount)
local saveData = GameSaveMgr:GetGameSaveData()
for _, item in ipairs(saveData.bagData) do
if item.item_id == itemId then
item.item_count = itemCount
end
end
GameSaveMgr:SaveGameSaveData()
BagMgr:SetItemCount(itemId, itemCount)
end
---背包界面
...
--通知存储类要修改道具数量
function BagPanel:SendSetItemCount(itemId, itemCount)
BagDBSet:SetItemCount(itemId, itemCount)
end
--修改道具数量显示
function BagPanel:SetItemCountShow(itemId, itemCount)
...
end
...
---主函数
GameSaveMgr:LoadGameSaveData()
InitMgrData()
BagPanel:SendSetItemCount(1, 100)
可能代码还是很晕,我还是上图吧。
就看这个弯弯绕绕的,就知道很麻烦。(我光作图就觉得麻烦的要死)
除此之外,还要注意保存存档数据完成后,通知修改数据区道具数量的时候,一定要注意再做一次只读表。
复杂度可显而知,而且调来调去这么多,维护起来也是十分的要命。
就在我用这一套逻辑和大佬说了之后,他听了半天终于明白了我的意图。觉得倒是可以,但是真的太麻烦了。而且就以正常联网游戏来说,存档不应该是这么刻意的,每个都要自己写。应该做到一种,我在内存随便改,最后只要统一的存档就行了。“要写起来很爽才对”。
听着好像明白了,但是又没有思路。大佬这时候点了个题,“你的存档数据不是应该保持不变的嘛,你的只读表的原数据,应该也是也和存档数据链着的。”
突然我就明白了,你可能还懵的,那么正文开始。
正文
这个存档,主要为了安全性和便利性。将存档做成只读表,同时将每一层表都与存档数据的表链起来。在模块管理器中,针对数据区只有两类方法。
一类是Get,从数据区的只读表中获取数据,以防止数据被暗改,十分安全。
另一类是Set,从数据区的只读表中获取原始表,直接修改存档数据,直接保存,十分便利。
先来看一下只读表的制作
Lua只读表
这里贴一下代码:
table.readOnly = function(sourceTable)
for k, v in pairs(sourceTable) do
if type(v) == 'table' then
sourceTable[k] = table.readOnly(v)
end
end
return setmetatable({
}, {
__index = sourceTable,
__newindex = function()
print(string.format("试图向只读表中插入或修改值 key[%s] value[%s]", k, v))
end,
})
end
不过这和存档的制作有点冲突,因为会把原始表中的表的链破坏掉。
其次,原始表还是有可能插值进去的,所以__index指向一个已经做好的表,显然会有问题。所以要用一个额外的表来记录子表的只读表。注意,这个额外的表不需要记录值类型的数据,不然原始表修改了只读表中读的还是错误的。
所以可以这样改一下,顺便把原始表带进去。
table.readOnly = function(sourceTable)
local lookupTable = {
}
return setmetatable({
}, {
__index = function(tb, k)
if type(sourceTable[k]) == "table" then
if lookupTable[k] == nil then
lookupTable[k] = table.readOnly(sourceTable[k])
end
return lookupTable[k]
else
return sourceTable[k]
end
end,
__newindex = function(tb, k, v)
Error(string.format("试图向只读表中插入或修改值 key[%s] value[%s]", k, v))
end,
__source = sourceTable,
})
end
这样动态的获取表中的数据,会处理的好一些。
同时也附上获取原始表的代码:
table.getSource = function(targetTable)
local metaTable = getmetatable(targetTable)
if metaTable ~= nil then
return metaTable.__source
else
return targetTable
end
end
关于只读表差不多就是这样了。
再说一下关于存档的另一件事情。就是初始化存档数据,毕竟有些值是要有初始值的。比如说可能有一个存档创建时间。这个这种数据可以在每次登陆的时候去检查是否存在,但是并不是很好,不太建议冗杂在业务逻辑里。所以可以这样:
function GameMgr:CreateSave()
GameSaveMgr:CreateGameSave()
DBDataInit()
end
local function TimeDBInit(initSaveData)
initSaveData.create_st = TimeHelper.GetCurTime()
end
_G.DBDataInit = function()
local initSaveData = {
}
TimeDBInit(initSaveData)
GameSaveMgr:SetGameSave(initSaveData)
GameSaveMgr:SaveGameSave()
end
也就是只在创建存档的时候才会初始化这些值,同时还可以集中处理。
接下来就是说一下修改存档方面的内容。
关于读取存档。加载存档的时候,将存档数据转为只读表,然后分别存到各个模块的管理器中。
local saveData = table.readOnly(GameSaveMgr:GetGameSaveData())
BagMgr:LoadBagData(saveData.bag_item_data)
以背包数据为例
---背包管理器类
BagMgr = {
}
--背包数据区
local BagData = {
}
--加载背包数据
function BagMgr:LoadBagData(bagData)
BagData = bagData
end
--获取道具数量
function BagMgr:GetItemCount(itemId)
for _, itemData in ipairs(BagData) do
if itemData.item_id == itemId then
return itemData.item_count
end
end
return 0
end
--修改道具数量
function BagMgr:SetItemCount(itemId, itemCount)
--获取数据区对应的数据
local targetItemData
for _, itemData in iparis(BagData) do
if itemData.item_id == itemId then
targetitemData = itemData
break
end
end
if targetItemData ~= nil then
local sourceItemData= table.getSource(targetItemData )
sourceItemData.item_count = itemCount
else
local sourceBagData= table.getSource(BagData)
table.insert(sourceBagData, {
item_id = itemId,
item_count = itemCount,
}
end
--存档
GameSaveMgr:SaveGameData()
--通知Panel修改显示
...
end
看起来就简单直了。
Get类的方法就是直接获取,因为获取的就是只读表,所以也不允许修改或添加值,因而十分的安全。
Set类的方法就是获取对应数据的原始表,这个表直接指向了存档数据,修改的值也是直接修改的存档数据的值。修改好了之后,直接存档,不用在意到底改了什么,也不用怕获取的时候会有问题。
写起来是真的很爽啊。