欢迎参与讨论,转载请注明出处。
本文转载自https://musoucrow.github.io/2018/07/27/dfq_res/
前言
在游戏开发的领域里,游戏资源的管理可谓一个很重要的基础功能,在一些强大的游戏引擎会为其配备一套解决方案。而LÖVE很不幸的再次没有提供,好在即使没有也起码做好了内存管理的工作,那么即便自己动手做一套也不是什么困难的事了,本文便记录其中心得。
资源管理模块本质上只做了两件事:
- 生命周期管理:保证多次加载时资源的复用,在无需该资源时进行销毁。
配置接口:提供加载资源的API,以及外部化的资源配置。
接下来便围绕以上两点展开说明其中的要点。
生命周期管理
如上文所言,生命周期管理要做的事即:保证多次加载时资源的复用,在无需该资源时进行销毁。资源复用的实现思路非常的简单,使用哈希表将资源以路径-对象为映射关系进行存储即可,然后每次加载资源时进行一次检查,若存在表内则直接获取,否则再进行读取。
local poor = {}
local function GetResource(path)
if (poor[path]) then
return poor[path]
end
local res = open(path)
--... load resource.
poor[path] = res
return res
end
接下来是第二个问题:在无需该资源时进行销毁。说得具体点便是:当外部没有对象引用该资源时,将其从资源池里移除。如此只需要使用弱引用即可,在Lua里即是建立弱表(weak table)。
local poor = {}
setmetatable(poor, {__mode = 'v'})
如此当poor内存在外部无引用的对象时,在垃圾回收时便会将其移除。如此资源的生命周期管理便算完成了。
配置接口
资源文件按照性质可以划分为两种:数据文件(二进制为主,如图片、声音等),配置文件(可编辑、可序列化的变量对象)。对于配置文件,Lua可以很方便地直接使用本体:
return {
x = 1,
y = 2
}
只要将其读取后使用loadstring(text)()
函数便可将其序列化,在其他引擎也有自定义配置格式以编辑器加持的形式解决,如Unity。现实情况中,一般数据文件会通过配置文件进行加载,即在配置文件提供对应资源的路径,然后由代码进行加载处理。
return {
image = "glow",
ox = 5,
oy = 5,
color = {
r = 255,
g = 255,
b = 255,
a = 127
}
}
如上配置所示,此配置的image项将会由代码根据配置提供的路径glow
进行读取对应目录下的image/glow.png
文件。如此便可看出,资源与资源之间存在很强的联动性,它们就像是一棵树,节节相扣。对于那些较上层的配置文件而言,往往会从上到下牵涉巨多资源。这么做是很棒的,一加载便将所有相关的资源都加载了,只要在恰当的场合进行资源加载(如切换地图),核心游戏过程中则几乎不会涉及到加载了。
配置的健壮性
在没有编辑器加持的情况下,单纯的配置文件健壮性是有限的,最突出的两个需求便是:
- 快捷定位路径:如位于
sprite/test/1.cfg
的配置文件想要读取位于同路径、不同分类下的image/test/1.png
文件,如果没有一些辅助手段,那么只能傻傻的输入全路径,十分愚蠢。 参数注入:倘若存在一些大体相似,少部分不同的配置需求,若没有参数注入,那么只好傻傻的批量复制修改,也是十分的愚蠢。
当然以上两点若是存在编辑器,自然可以无视并通过自动化手段之类达到相同的效果。但目前项目暂无编辑器,于是采用了替换文本的方案。
---sprite/test/1.cfg
return {
image = "$A",
sx = $1,
sy = $2,
}
如上配置所示,$A
便是代表当前资源分类下的路径,即替换为test/1
,如此便可快速定位至image/test/1.png
,算是一种语法糖吧。至于$1 $2
则代表第1、第2个注入的参数,在调用的API的时候会以{1.2, 1}
的形式作为参数填入。如此便会将$1
替换为1.2
,同理$2
替换为1
。当然这种注入了参数的配置在资源池的key是不能使用路径的(不是标准的),会在其后加入参数值成为:test/1|1.2|1
。
配置的只读性
由于资源对象往往都是独一一份,到处引用,倘若哪处不小心对其进行了修改,那么便会引起连锁反应影响全局。所以有必要考虑将资源对象设置为只读的:
local function _PrivateTips()
assert(nil, "The table is const.")
end
function _TABLE.NewConst(tab)
local tabMt = getmetatable(tab)
if (not tabMt) then
tabMt = {}
setmetatable(tab, tabMt)
end
local const = tabMt.__const
if (not const) then
const = {}
tabMt.__const = const
local constMt = {
__index = tab,
__newindex = _PrivateTips,
__const = const
}
setmetatable(const, constMt)
end
for k, v in pairs(tab) do
if (type(v) == "table") then
tab[k] = _TABLE.NewConst(v)
end
end
return const
end
只要将对象拿去处理后,试图修改该对象时将会报错。当然这样做是有代价的:pairs()
和table.getn
函数将会变得无法直接使用,需要取出其元表方可使用。所以需要配备专门函数:
function _TABLE.Len(tab)
local meta = getmetatable(tab)
if (meta and meta.__index) then
return #meta.__index
else
return #tab
end
end
function _TABLE.Pairs(tab)
local meta = getmetatable(tab)
if (meta and meta.__index) then
return pairs(meta.__index)
else
return pairs(tab)
end
end
这样子使用起来虽然麻烦了点,不过的确将资源对象和一般对象作出了明显的区分。另外只读处理的时机也需要考量的,一般得在整个资源对象处理完毕后才进行。
配置的个性化
对于一些普遍的资源文件(图片、声音、精灵、动画等),一般配备专属的处理函数即可。但是到了业务层面情况往往会繁杂许多,将会存在许多个性化的配置格式。这时候便需要将业务对象和资源对象进行绑定:
return {
script = "$A",
tagMap = {
attack = true,
attackRate = true,
autoPlay = true
},
stopTime = 200,
endTime = 300,
nextState = "stay",
frameaniPath = "$0attack1",
actor = "bullet/throwstone",
bulletPos = {
x = 20,
y = 0,
z = -60
}
}
如上配置所示,这是一个哥布林的投掷状态,这里的配置便需要提供子弹资源以及发射坐标了。关于这些个性化的配置项,将会如此解决:
local function _NewStateData(path, keys)
local data, path = _RESOURCE.ReadConfig(path, "config/actor/state/%s.cfg", keys)
data.class = require("actor/state/" .. data.script)
data.script = nil
if (data.class.HandleData) then
data.class.HandleData(data)
end
return data
end
可以看到,通过配置的script项找到对应的脚本业务对象,然后调用其对象的HandleData函数进行解析。如此便解决了个性化的问题。
后记
还是如上篇一般,这个问题对于流行的大引擎而言已经提供了成熟的解决方案。上了贼船呀,只能走到黑了。不过造造轮子也是有益技术的提升的。