Fork me on GitHub

/无间落叶 I am a leaf on the wind ~

如何优雅的管理游戏资源

| Comments

[无间落叶]http://blog.leafsoar.com/archives/2013/11-27.html

在游戏的开发过程中,前期的规划 往往比 后期的“优化”更为重要!比如多分辨率适配,如果前期没有规划好,可能导致的情况是,画面只在当前测试开发机或者一部分机型正常显示。做了多套资源适配,可以使在合适的机型使用对应的图片资源,避免在高清屏幕使用低质量的图片,在低分辨率屏幕因为图片太大而浪费硬件资源。机制与策略分离,可以让你设计出简单有效的接口。模块化的设计可以让你组织好各种逻辑流程,条理分明 ~ 前期的规划工作可以有很多,一叶也在摸索之中,以使游戏的开发尽量变的简单灵活且可控。最简单的也是最容易忽略的地方,跟我们打交道最多的要数精灵了,从图片创建一个精灵,很简单的开端,将以此展开行动 ~

本文使用 Cocos2d-3.0alpha1 版本,创建了一个 C++ 项目,介绍在 C++ 中,如何处理资源相关的内容,如果读者使用脚本,也可以参考本文中资源管理理念而忽略语言特性,你可以在 Github1 上面看到本文所有源码。

名字系统

也许你可称之为“命名规范”,但显然它无法表达我所想说的内容,很多人在创建精灵的时候喜欢直接使用资源名称,而没有任何定义,这是一个不好的习惯,如果游戏资源不存在,缺少,或者修改名字,如此你需要在多出引用的地方一一修改。游戏开发中的变数总是无法预料,合理的“名字系统”可以节省很多人力。

我们设定一个文件,这里名为 “Resources.h” 的文件,在其中定义所有的资源名称,在游戏开发中,尽量只 使用此处的名称,如图片名称,字体名称,声音资源等。这样做有以下好处,只是简单说几点:

  • 如果对资源做出修改,我们可以修改此处定义,以保证同步,避免缺失,命名错误,错误引用等问题
  • 在图片名定义修改时,编译器会编译出错,并自动帮助我们 “找” 出引用的地方,方便修改
  • 由于有常量定义的缘故,我们的 IDE 会自动补全所有以定义变量名称,减少出错的可能,提高效率
  • 这个文件列表显然可以写一个如 python 脚本自动生成

使用脚本来自动生成文件常量定义显然是个行之有效的途径,这种机械式的操作交给脚本就行了,它总能出色的完成任务,首先来看看项目的 Resources 目录内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
[Resources]~ ./tree
├── CloseNormal.png
├── CloseSelected.png
├── HelloWorld.png
├── file_list.json
├── fonts
│   └── Marker\ Felt.ttf
└── images
    ├── JungleLeft.png
    ├── ghosts.plist
    ├── ghosts.png
    ├── grossini_family.plist
    └── grossini_family.png

以上是资源文件,那么通过脚本所生成的 “Resources.h” 文件又是什么样子的呢,脚本在 Github 仓库中可以找到(注意:资源名中最好不要有空格,以免留下“隐患”):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#ifndef _AUTO_RESOURCES_H_
#define _AUTO_RESOURCES_H_

// search paths
static const std::vector<std::string> searchPaths = {
  "fonts",
  "images",
};

// files
static const char si_CloseNormal[]         = "CloseNormal.png";
static const char si_CloseSelected[]       = "CloseSelected.png";
static const char sjs_file_list[]      = "file_list.json";
static const char si_HelloWorld[]      = "HelloWorld.png";
static const char st_MarkerFelt[]      = "Marker Felt.ttf";
static const char sp_ghosts[]      = "ghosts.plist";
static const char si_ghosts[]      = "ghosts.png";
static const char sp_grossini_family[]         = "grossini_family.plist";
static const char si_grossini_family[]         = "grossini_family.png";
static const char si_JungleLeft[]      = "JungleLeft.png";

#endif // _AUTO_RESOURCES_H_

看到通过脚本,我们生成了所有文件的常量定义,这让得我们可以在游戏中任意使用,但是请注意,这里生成的文件名称是没有包含路径的,所以在定义文件之前,也自动生成了目录列表 searchPaths,顾名思义,设定了一个目录列表,以便找寻资源,我们可以在程序的开始处使用 FileUtils::getInstance()->setSearchPaths(searchPaths); 来设定游戏的资源目录列表,这样我们就可以不用关心资源所在的目录了,你甚至可以根据需要合理的调整资源目录。

注意:通过设置 searchPaths 可以让我们不用关系资源的路径所在,那么意味着资源名称必须唯一,否则可能会出现引用问题。其次,是如果使用了多套资源方案,请注意 searchPaths 的先后顺序关系。本文暂不考虑多套资源。关于忽略资源目录的做法,如果有不同看法者,欢迎留言讨论,对我来说,忽略路径是利大于弊的 ~

以上通过脚本自动生成了文件列表,但是这显然不够,我们看到资源当中有两张 打包 资源图片(可以使用 TexturePacker 对图片资源进行打包,具有占用更小空间,优化运行效率等诸多好处,后面还会介绍此点) plist 文件。我们当然也是需要使用打包中资源的,所以脚本需要能够自动解析 plist 文件,并提取出 TexturePacker 打包的资源名称,请看如下定义,同样是自动生成在 “Resources.h” 文件之中:

1
2
3
4
5
6
7
8
9
10
11
12
////// texture //////

// ghosts.plist
static const char si_child1[]      = "child1.gif";
static const char si_father[]      = "father.gif";
static const char si_sister1[]         = "sister1.gif";
static const char si_sister2[]         = "sister2.gif";

// grossini_family.plist
static const char si_grossini[]        = "grossini.png";
static const char si_grossinis_sister1[]       = "grossinis_sister1.png";
static const char si_grossinis_sister2[]       = "grossinis_sister2.png";

此时我们就能用以下代码来创建精灵了,都引用了资源名称定义,并且使用两种方式创建了精灵:

1
2
3
4
5
6
// 直接由图片创建精灵
auto hello = Sprite::create(si_HelloWorld);

// 从打包资源创建精灵
SpriteFrameCache::getInstance()->addSpriteFramesWithFile(sp_grossini_family, si_grossini_family);
auto sister = Sprite::createWithSpriteFrameName(si_grossinis_sister1);

上面我们使用两种方式创建精灵,为什么会有两种方式?也许你可以看看 『子龙山人』 翻译的文章 『在cocos2d里面如何使用Texture Packer和像素格式来优化spritesheet』 其中详细的介绍了图片资源打包优化的相关细节问题,一个游戏最多的就是图片资源,优化空间最大的也是图片资源,里面详细的介绍了优化图片资源占用空间 50% 以上,如何使游戏运行内存占用优化近 50%,以 cocos2d 为例,但 cocos2d-x 同样能够适用,而且能通过脚本自动打包。 所以合理的对图片资源进行打包优化是非常有必要的。但如何处理这个流程确实不好定夺,因为不同资源的使用方式不同,因为这两种方式的存在,导致我们编写代码的逻辑不同,这需要提前预定好,所以我们考虑如下开发流程:

在游戏开发前,对所有资源打包后提供给 编写游戏人员,也就是说在写程序之前,游戏资源就已确定,那些以打包,哪些未打包都已经知道,如前面一样,通过两种方式创建精灵。但是这样的结果是,前期规定好了的,后期就无法改动,或者说很难改动,牵一发而动全身啊 ~ 这就需要加大 前期的规划 力度,以确保后期不会出现太大太多事与愿违的情形。这种情况下的 后期优化 将会非常蹩脚。况且加大前期规划的力度,可能会对整个项目的进程有所影响,如比编写人员的动工会稍缓,人力资源分配不合理。

透明

前文提到,我们使用了 searchPaths 变量,以用忽略资源的路径,一个存在的东西,看起来好像不存在一样,我们称之为 “透明”,”透明” 在软件领域中也是重要的概念,它也强调着封装的重要性,隐藏细节的必要性。这里的资源路径就是如此,我们可以说 对于资源的使用来说,它的路径是透明的, 有没有路径,路径为何?那不重要,重要的是你能通过资源名称获取想要的资源。

也许你已经发现了,我想说的不是路径问题,而是图片资源问题。对于图片资源的使用来说,它’是否是打包资源’ 应该是透明的。也既是在使用图片资源的时候,你不应该关心它是不是打包后的资源,是也好,不是也罢,这不应该影响你对资源的请求和使用。”打包” 这个过程对你来说,不存 ~

图片资源类型的 “透明化” 处理

先来段代码,看看没有 “透明化” 处理时的一般使用方式:

1
2
3
4
5
6
// 方式一  文件资源
auto jungle = Sprite::create(si_JungleLeft);

// 方式二  打包资源
SpriteFrameCache::getInstance()->addSpriteFramesWithFile(sp_grossini_family, si_grossini_family);
auto sister = Sprite::createWithSpriteFrameName(si_grossinis_sister1);

以上我们看到,同样是创建精灵, si_JungleLeft 是普通的 文件资源,而 si_grossinis_sister1打包资源,这决定着两者的使用方式不同,那么怎么 “透明化” 处理呢:

1
2
3
// 不论图片属于 文件资源 还是 打包资源 使用方法相同
auto jungle = AssetLoader::createSprite(si_JungleLeft);
auto jungle = AssetLoader::createSprite(si_grossinis_sister1);

我们提供了一个类 AssetLoader,它有一个方法 createSprite(const std::string& name)。不论我们是不是打包资源,我们都通过这个方法来创建精灵,显然它的内部工作原理是根据图片的实际类型,动态判断并创建,之后返回,要实现这样一个功能是可行的,并且没有多复杂,实现以后。我们在使用图片资源的使用就再也用关心它是什么类型的资源了。

这也意味着你可以以一个理想的方式来管理开发流程。 图片资源可以和游戏编写同时进行,不停的添加图片资源,不停的编写游戏逻辑,而不用考虑图片是否已经优化的问题了,此时可以提供一些零散的图片,以供使用(图片命名最好还是固定),当然也可以提前把关联性比较强的图片提前打包处理,这并不影响使用,因为对程序来说,它是透明的,”不存在”的。在后期,我们可以集中的在后期对游戏资源优化,打包处理等(关于此点,文章后面也会给出相对合理的处理流程)。

功能的实现方案与流程

在开始之前,一叶通常会将其流程在心中演算一遍,使其不会出现太大的纰漏,对于不合理的所在,可以重新拟定方案。然后实现之 ~ 要使得 AssetLoadercreateSprite 方法完成其功能,那么它需要知道,当前请求的 图片资源 是否是 文件资源(以 文件资源打包资源 区分两者),如果是,直接由前文 方式一 创建精灵返回,如果不是,则从 打包资源 里面找寻,找到就通过 方式二 创建精灵并返回,如果还没找到,就返回空指针喽 ~ 由此我们知道 AssetLoader 它内部需要完成以下功能:

  • 能够判断一个资源是否是文件资源
  • 能够根据打包资源图片名称返回实际的 plist 文件(打包资源描述文件)和 图片文件

要完成以上功能,那就需要让 AssetLoader 知道有哪些文件资源,还要知道有哪些打包资源,我们可以在 AssetLoader 里面定义几个字典用以保存这些数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class AssetLoader: public Object{
public:
    static Sprite* createSprite(const std::string& name);

private:
    static AssetLoader* getInstance();
    bool init();

    bool fileExists(const std::string& filename);
    std::string getTexturePlist(const std::string& name);
    std::string getTextureImage(const std::string& name);

private:
    Dictionary* _fileDict;              // 文件列表
    Dictionary* _texturePlistDict;           // 打包资源到文件的映射
    Dictionary* _textureImageDict;       // 打包资源plist 到图片的映射
};

如以上的定义实现,它有三个字典, _fileDictkey 保存着所有文件资源,value 保存文件资源的 编号 ,这样我们就能够随时判断一个图片是否是文件资源了。_texturePlistDict 的 key 保存着打包资源的名称,value 保存打包资源所在 Plist 文件的编号,通过它我们能通过打包资源获取到它 Plist 所在的文件。 _textureImageDict 也是类似,key 保存打包资源名称,value 保存打包资源所在的真实 图片文件的引用。

功能已经定义完毕,现在的问题是我们如何去为这几个字典填充数据?显然程序初始化手动填充不靠谱,前文的文件名等信息都已经是自动定义了,此处我们当然也希望有一个方案 自动填充 了。这里的做法是,在使用 python 生成资源定义的时候,同时生成一个 json 文件,这个文件里面包含了所有此处字典中所需要的数据,然后 AssetLoader 初始化的时候读取这个 json 文件,以完成自动填充数据的功能。先来看看自动生成的 json 文件长什么样纸:

题外话:使用 json 来存储这样一个中转的数据格式是最后定下来的方案,设计之初考虑过几种方案,比如想到可以用一个 sqlite 数据来保存各种数据,这样数据的操作就非常统一,对后期的数据统计分析也会非常方便,曾与朋友 子龙山人 讨论过这之间的详细细节,以及各种实现方案的利弊分析。使用 sqlite 的好处是更为灵活,后期扩展功能会非常方便,适合稍微大点的项目,但是如果一个项目本身没有使用 sqlite 数据库,如果为这里的方案而硬添加一个扩展库实现 sqlite,可能就会非常的不友好,不通用。

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
{
  "file_name": [
    "CloseNormal.png",
    "CloseSelected.png",
    "file_list.json",
    "HelloWorld.png",
    "Marker Felt.ttf",
    "ghosts.plist",
    "ghosts.png",
    "grossini_family.plist",
    "grossini_family.png",
    "JungleLeft.png"
  ],
  "file_index": [
      "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"
  ],
  "texture_name": [
    "child1.gif",
    "father.gif",
    "sister1.gif",
    "sister2.gif",
    "grossini.png",
    "grossinis_sister1.png",
    "grossinis_sister2.png"
  ],
  "texture_plist": [
    "6", "6", "6", "6", "8", "8", "8"
  ],
  "texture_image": [
    "7", "7", "7", "7", "9", "9", "9"
  ]
 }

以上是自动生成的 json 数据文件内容,为了在这里展示,做了点格式化和缩进,更为友好一点。通过 file_namefile_index 可以创建文件资源列表字典,通过 texture_name 和 texture_plist 可以创建打包资源和文件资源之间的映射, texture_image 也是同样。 file_name 包含了所有文件资源,file_index 为文件资源做了编号,这两个数据项的个数是相同的。 texture_name 定义了所有打包资源的定义,texture_plist 和 texture_image 则保存了 打包资源所在的 plist 文件和图片文件的引用,它们的数据项个数也是相同的。只要保证这里生成的内容没有错误,那么我们就正确的将其填充到 AssetLoader 的字典里面,以实现想要的功能。

为什么数据会长这个样子?一叶本来的设计 json 文件,多层嵌套更具描述性(各种对象,各种属性,一目了然),但是发现 解析的时候稍显麻烦,在 新版 cocos2d-x 的 gui 库中,已经封装好了一些常用的 json 解析功能,本着 拿来注意(尽可能的寻找可用的资源来简化自身的流程) 的思想,为使解析过程简单,所以数据格式就定义成那个样子了 - =。现在只是用了五个数组(更平面化的数据组织,像是数据库表),保存所有数据。只能说,这样做是为了迎合代码的编写,让本来复杂的 json 解析过程变得更为简单。现在看来,但到也简单清晰,看看填充字典的关键代码实现(如果有其它更好的方式,修改也不麻烦,修改生成的数据格式,再修改代码中数据的填充方法就行了):

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
JsonDictionary *jsonDict = new JsonDictionary();
String* fileContent = String::createWithContentsOfFile(sjs_file_list);
jsonDict->initWithDescription(fileContent->getCString());

DictionaryHelper* dicHelper = DICTOOL;

_fileDict = Dictionary::create();
int file_idx = dicHelper->getArrayCount_json(jsonDict, file_name);
for (int i = 0; i < file_idx; i++){
    std::string name = dicHelper->getStringValueFromArray_json(jsonDict, file_name, i);
    std::string index = dicHelper->getStringValueFromArray_json(jsonDict, file_index, i);
    _fileDict->setObject(String::create(index), name);
}
log("file count: %d", file_idx);

_texturePlistDict = Dictionary::create();
int texture_idx = dicHelper->getArrayCount_json(jsonDict, texture_name);
for (int i = 0; i < texture_idx; i++){
    std::string name = dicHelper->getStringValueFromArray_json(jsonDict, texture_name, i);
    std::string plist = dicHelper->getStringValueFromArray_json(jsonDict, texture_plist, i);
    _texturePlistDict->setObject(String::create(plist), name);
}
log("texture count: %d", texture_idx);

_textureImageDict = Dictionary::create();
for (int i = 0; i < texture_idx; i++){
    std::string name = dicHelper->getStringValueFromArray_json(jsonDict, texture_name, i);
    std::string image = dicHelper->getStringValueFromArray_json(jsonDict, texture_image, i);
    _textureImageDict->setObject(String::create(image), name);
}

CC_SAFE_DELETE(jsonDict);

这里能看到一些陌生的内容 JsonDictionaryDictionaryHelper 类型和其操作方式,这里的使用方法不是本文的重点,有兴趣的朋友看看源码实现。以很简洁的方式,填充了我们需要的字典数据内容。有了这些字典数据,我们就很容易的判断一个图片是否是文件资源了,如果是打包资源,也能够很容易找出打包资源所在的 Plist 文件和 图片文件,最后看一下 createSprite 方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Sprite* AssetLoader::createSprite(const std::string& name){
    if (AssetLoader::getInstance()->fileExists(name)){
        return Sprite::create(name);
    }
    log("create sprite: %s", name.c_str());
    std::string plistfile = AssetLoader::getInstance()->getTexturePlist(name);
    std::string imagefile = AssetLoader::getInstance()->getTextureImage(name);
    log("plist: %s, image: %s", plistfile.c_str(), imagefile.c_str());
    if (plistfile != "" && imagefile != ""){
        SpriteFrameCache::getInstance()->addSpriteFramesWithFile(plistfile, imagefile);
        return Sprite::createWithSpriteFrameName(name);
    }
    return nullptr;
}

至此我们便完成了 图片资源类型的 “透明化” 处理 。这样一个解决方案,很好的解决了在开发过程中图片资源的管理过程,后期优化,都不冲突。能够通过此提供一个较为合理的开发流程。 本文所使用的源码,脚本等都可以在 Github 上面找到 『https://github.com/leafsoar/resource-manager』,但是要清楚,我这里提供的只是按照我这种流程下来的一种实现而已,对于程序本身而言,也还有很多可以改进的所在 ~ 思路同样,每个人实现的具体细节可能不一样,不论你使用 C++ 还是脚本语言,都不影响你 “透明化” 图片资源类型。

如何优雅的管理游戏资源

我们解决了一些问题,提供了一些解决方案,但总有更多的问题等着我们去解决,更多的优秀解决方案,好的工作模式,处理流程。我们会把开发中一些 变动 的所在找寻出来,对它灵活的处理,使它能够适应各种不同的 险恶环境。哪些是不变的,哪些是容易变动的,尽量做到 以不变应万变。现在新的问题和需求又来了,哈 ~

继续前文内容,我们可以使用 AssetLoader 来加载图片资源,创建精灵,实现游戏玩法逻辑等。但是我们通常会在一个场景进入时就预先缓存所有图片资源(声音资源亦是同样),甚至在游戏开始时,预先加载所有的图片资源,以 保证游戏画面的流畅性。如果没有预先缓存图片资源,那么在游戏中用到的时候,实时加载可能 会使游戏画面卡顿,这不是我们想看到的结果。如果一个游戏不大,资源总和也没多少,那么可以直接在游戏开始时全部加载完毕,这种情况处理起来比较简单,直接把所有资源加载就可以了。但是如今的游戏动辄几十兆,几百兆,显然游戏资源一次性加载是不科学的,这时我们可以分场景,在加载一个场景的时候,清空前一个场景所使用的图片缓存资源,然后预先加载当前场景的游戏资源,以达到最优的内存占用。

通常我们都是人为的,定义了一个方法在开始场景前做一些准备工作,清空缓存,预加载游戏资源,如这里有一个需要预加载的资源列表,而前文我们提到,在游戏开发的过程中,我们的图片资源可能会有所改动,这就需要我们去 人为的同步去手动维护这个列表,而这样的工作费时费力,还容易出现很多错误,如果我们能够把这一步的操作自动化,根据实际情况生成其列表,并且 列表资源的加载顺序也是做过优化的(根据文件大小,或者分辨率大小,优先加载大的资源,使游戏减少因占用内存过多而崩溃的可能性),那将使我们能有 更多的精力花在更值得的地方。如果结合到本文之前的实现方案就是,在开始一个场景时,我们对 AssetLoader 做一个标记,在这个标记之后所请求的图片资源都是当前场景的资源,我们可以在内部将其记录下来,以任何方式都行,这样我们就能够非常容易的收集并生成当前场景所使用的图片资源了。如果我们将这个列表做成动态可维护的,自动记录以便下次运行时预先加载,这样一种实现从逻辑上来说时可行。如何优雅得管理游戏资源?但是实现比 优雅 更重要,在实现的过程中,尽量使开发变得简单,流程变得清晰,也是一叶努力的方向 ~

以上只是对预加载资源列表的动态维护,提供了一个简单的思路,其中还有很多细节值得推敲。但我想实现这样一种流程对游戏的开发是非常有帮助的,对于这个部分的内容,一叶还没有给出一个具体的实现方案,但将继续之前的流程往下实现,并分享在 Github 上面,同时你也可以参与进来。也算是在这里集思广益,如果你有什么好的想法,对本文实现有什么改进,都可以一起交流。如果你遇到了相同的问题,也可以说说你是怎么处理这些问题的,欢迎分享 ~

Comments