《DFQ》开发随录——资源管理

  欢迎参与讨论,转载请注明出处。

前言

  在游戏开发的领域里,游戏资源的管理可谓一个很重要的基础功能,在一些强大的游戏引擎会为其配备一套解决方案。而LÖVE很不幸的再次没有提供,好在即使没有也起码做好了内存管理的工作,那么即便自己动手做一套也不是什么困难的事了,本文便记录其中心得。
  资源管理模块本质上只做了两件事:

  • 生命周期管理:保证多次加载时资源的复用,在无需该资源时进行销毁。
  • 配置接口:提供加载资源的API,以及外部化的资源配置。

  接下来便围绕以上两点展开说明其中的要点。

生命周期管理

  如上文所言,生命周期管理要做的事即:保证多次加载时资源的复用,在无需该资源时进行销毁。资源复用的实现思路非常的简单,使用哈希表将资源以路径-对象为映射关系进行存储即可,然后每次加载资源时进行一次检查,若存在表内则直接获取,否则再进行读取。

1
2
3
4
5
6
7
8
9
10
11
12
13
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)

1
2
local poor = {}
setmetatable(poor, {__mode = 'v'})

  如此当poor内存在外部无引用的对象时,在垃圾回收时便会将其移除。如此资源的生命周期管理便算完成了。

配置接口

  资源文件按照性质可以划分为两种:数据文件(二进制为主,如图片、声音等),配置文件(可编辑、可序列化的变量对象)。对于配置文件,Lua可以很方便地直接使用本体:

1
2
3
4
return {
x = 1,
y = 2
}

  只要将其读取后使用loadstring(text)()函数便可将其序列化,在其他引擎也有自定义配置格式以编辑器加持的形式解决,如Unity。现实情况中,一般数据文件会通过配置文件进行加载,即在配置文件提供对应资源的路径,然后由代码进行加载处理。

1
2
3
4
5
6
7
8
9
10
11
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文件,如果没有一些辅助手段,那么只能傻傻的输入全路径,十分愚蠢。
  • 参数注入:倘若存在一些大体相似,少部分不同的配置需求,若没有参数注入,那么只好傻傻的批量复制修改,也是十分的愚蠢。

  当然以上两点若是存在编辑器,自然可以无视并通过自动化手段之类达到相同的效果。但目前项目暂无编辑器,于是采用了替换文本的方案。

1
2
3
4
5
6
---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

配置的只读性

  由于资源对象往往都是独一一份,到处引用,倘若哪处不小心对其进行了修改,那么便会引起连锁反应影响全局。所以有必要考虑将资源对象设置为只读的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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函数将会变得无法直接使用,需要取出其元表方可使用。所以需要配备专门函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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

  这样子使用起来虽然麻烦了点,不过的确将资源对象和一般对象作出了明显的区分。另外只读处理的时机也需要考量的,一般得在整个资源对象处理完毕后才进行。

配置的个性化

  对于一些普遍的资源文件(图片、声音、精灵、动画等),一般配备专属的处理函数即可。但是到了业务层面情况往往会繁杂许多,将会存在许多个性化的配置格式。这时候便需要将业务对象和资源对象进行绑定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
}
}

  如上配置所示,这是一个哥布林的投掷状态,这里的配置便需要提供子弹资源以及发射坐标了。关于这些个性化的配置项,将会如此解决:

1
2
3
4
5
6
7
8
9
10
11
12
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函数进行解析。如此便解决了个性化的问题。

后记

  还是如上篇一般,这个问题对于流行的大引擎而言已经提供了成熟的解决方案。上了贼船呀,只能走到黑了。不过造造轮子也是有益技术的提升的。