前言
身在杭州,看着上海垃圾分类如火如荼的进行,内心不免有些慌乱,为了更好、更有趣的学习垃圾分类知识,我和小伙伴利用业余时间开发了一款垃圾分类游戏,我们首先确定了基调,游戏要有魔性的画风、粗糙的风格,但粗中有细,简单有趣,又富有挑战性,下面是游戏的预览图和视频。
游戏视频: www.bilibili.com/video/av627…
游戏已上架 App Store,打开 App Store 搜索 垃圾分类王 即可下载,或者点击这里直接跳转到 App Store 下载,欢迎大家下载,祝大家游戏愉快。
技术栈
游戏的核心元素包括小人、垃圾、垃圾桶三部分,人物的左手、右手、头部、左脚和右脚各能容纳一个垃圾并外显,当点击底部区域时投掷相应的垃圾,根据人物和垃圾桶的相对位置判断垃圾投到了桶内还是地面上。
根据游戏的核心元素,我们的人物和垃圾采用了骨骼动画形式,以方便“插拔”到人物身上,垃圾桶采用静态贴图,垃圾与垃圾桶、地面的碰撞检测直接基于坐标计算,使用的框架如下:
- 游戏框架 Cocos2d-x
- 骨骼动画框架 DragonBones
- Crash收集 Bugly
- 数据治理 UMeng
- 商业化 AdMob
在使用这些框架的过程中踩了一些坑,也做了一些总结,本文将从骨骼动画、资源动态化、内存数据保护三个角度介绍。
技术细节
骨骼动画
骨骼资源的制作
该游戏的核心是那个魔性的小人,说起这个人物,要追溯到笔者的大学时代,那时候对暴漫十分着迷,手绘了很多暴漫的人物和漫画,该游戏的角色就来自于多年前的那张手绘:
笔者首先将图片导入 js,放大数倍后使用毛笔工具描边,随后按照头、躯干、左右手、左右脚进行分割,得到一系列图片: default_tex.png
随后通过 DragonBones 提供的 PS 插件将他们导入到 DragonBones,按照不同部分摆放、绑定骨骼,并创建工程:
为了保证人物的头部、双手和双脚都能够“装备”垃圾,这里预留了 5 根骨骼和相应的 Slot,以人物左边的手(即人物右手)为例:
这里给手骨 l-hand 添加了一个子骨骼 weaponBoneL 以及插槽 weaponSlotL 来实现动态装载子骨骼,需要注意的是,这里的 weaponSlotL 图片不能为空,否则在运行时动态替换 Slot 时会抛出错误,笔者的做法是使用一张1x1的透明图片占位。
骨骼动画录制
在设计好骨骼后,下一步就是人物的动画了,在该游戏中人物只有站立和行走两个动画。对于行走动画,笔者采用了双腿交叉来模拟移动,同时大臂、小臂和手交替晃动来模拟人保持平衡:
人物站立的动画,仅仅包含了头部和双手的微动:
装备骨骼的制作
装备骨骼的制作相对简单,只需要准备装备贴图,并用一根骨骼进行绑定。 性。
对于 RPG 游戏,最好使用一根有长度的骨骼来绑定图片,以便调整贴图与骨骼的相对方向,来保证装备后的视觉效果正确。
运行时动态换装
网络上关于 DragonBones 动态换装的文章较少,笔者经过查阅大量资料和摸索,总结出了一套较为稳定的换装方法。
第一步是获取装备的插槽,在上文中讲到为人物的右手增加了一个 weaponBoneL 骨骼和对应的 weaponSlotL 插槽,如果要向右手插入装备,只需要获取 weaponSlotL 插槽,下面的代码节选自游戏源码。
Slot* Role::getSlotByName(const std::string &name) {
// _body 是人物的 armatureDisplay 骨骼对象
Slot *s = _body->getArmature()->getSlot(name);
CCAssert(s != nullptr, "the slot is null");
return s;
}
复制代码
获取到 Slot 后,就可以将另外一个骨骼的 Armature 插入其中了,代码如下。
// 这里的 slot 即通过上文的 getSlot 获取到的手部 slot,node 即需要插入手部的骨骼对象
void Role::setSlot(dragonBones::Slot *slot, dragonBones::CCArmatureDisplay *node) {
if (slot == nullptr) {
return;
}
slot->setDisplay(node->getArmature(), dragonBones::DisplayType::Armature);
}
复制代码
总结一下,换装分为三步,先准备好人物骨骼 RoleBone 和装备骨骼 EquipmentBone,随后获取 RoleBone 的 Slot,最后将 EquipmentBone 的 Armature 插入其中。
需要注意的是,在卸载装备时,不能直接给 Slot 设置一个空,否则再次插入时会抛出错误,这里的方案也是插入一个透明占位图骨骼。
资源动态化
现代游戏都具有很强的热更新能力,一方面是基于脚本的逻辑动态化能力,另一方面是基于资源补丁的资源动态化能力,由于开发时间短,笔者与小伙伴在开发游戏时没有接入 Cocos2d-x JSB,采用了纯 C++ 的开发方式,只对资源进行了动态化。
资源动态化有两种方式,其一是使用 Cocos2d-x 提供的 AssetsManager 类,其二是自己实现一套资源补丁系统。由于前者设计的较为复杂,且文档较少,因此笔者采用了自主开发的方式。
为了实现资源的动态化,就要保证所有资源的路径不能写死,而是要采用查表的方式,这是实现资源动态化的关键。
资源路径表
资源路径表由资源描述符 ResourceMapItem 组成,每个资源描述符包含 namespace、key 和 path 三个部分,结构如下。
class ResourceMapItem {
public:
std::string ns;
std::string key;
std::string path;
static ResourceMapItem* fromValueMap(const std::string &ns, const std::string &key, const cocos2d::ValueMap &vm);
};
复制代码
游戏的所有资源都需要录入到资源路径表,形式如下。
通过加载资源描述符构建出资源路径表,通过 namespace + key 的方式查询实际路径,路径查找通过 R 函数实现,例如查找 local_storage 文件的路径,则使用 R("configs", "stage")
,这里模仿了 Android 对本地资源的管理方式。
虽然索引包含了 namespace 和 key 两部分,但是没必要建立一个二级索引表,只需要将 namespace + key 组合出一个唯一索引即可,资源路径表的结构如下。
class ResourceMap {
public:
std::string version;
std::map<std::string, ResourceMapItem *> items;
static ResourceMap* fromValueMap(const cocos2d::ValueMap &vm);
static std::string genKey(const std::string &ns, const std::string &key);
};
复制代码
在加载资源描述符时,首先通过 genKey 方法生成索引,然后存储到 items 这个一级索引表即可,读取配置时,同样通过传入的 namespace 和 key 调用 genKey 方法生成索引,查询 items 表即可,代码如下。
#define R(ns, key) ResourceManager::getInstance()->getResourcePath(ns, key)
std::string ResourceManager::getResourcePath(const std::string &ns, const std::string &key) {
// 生成索引 key
std::string resourceKey = ResourceMap::genKey(ns, key);
if (resourceMap == nullptr) {
CCAssert(false, "resource map is null");
return "";
}
if (resourceMap->items.find(resourceKey) == resourceMap->items.end()) {
CCAssert(false, "cannot find resource, maybe the patch is damaged");
return "";
}
// 查表获取描述符,进而获取到 path
std::string path = resourceMap->items[resourceKey]->path;
// 这里是对形如 ${ConfigsDir} 的路径变量做解析,这是为了处理 iOS 沙盒路径动态生成的问题
RMFileUtil::resolvePath(path);
return path;
}
复制代码
有了资源路径表以后,只需要在启动时选择加载不同的资源路径表,即可实现资源路径的动态化,为资源动态化打下了基础。
资源补丁设计
参考 Cocos2d-x 的 AssertManager 设计,一个补丁包含 manifest.json 描述文件和资源列表,结构如下。
首先是目录结构:
随后是描述文件 manifest.json:
{
"version": "patch_192001",
"role": {
"default": {
"rpath": "role"
}
},
"rubbish": {
"config": {
"rpath": "rubbish/rubbish.plist"
},
"milk": {
"rpath": "rubbish"
}
}
}
复制代码
manifest 指明了补丁名称、要 patch 的资源所在路径,这里包含了对角色和对垃圾的 patch。
补丁以压缩包的形式上传到 CDN,在游戏启动时获取当前版本的 patch 列表,patch 列表中会包含当前版本的 patch name 和 path:
{
"patch": {
"version": "1.0",
"patch_list": [
{
"name": "patch_192001",
"url": "http://somecdn.com/patch_192001.zip"
}
]
}
}
复制代码
本地处理的方式为下载、解压,解析 manifest.json,随后根据资源的 key 和 value 对资源路径表进行修改,只要保证补丁资源解压的路径被正确的写入资源路径表,即可实现资源的动态化。
这里有一个细节是对 patch 是否成功的判断,笔者采用的方法是在将新的资源路径写入资源路径表后,在补丁解压的目录放置一个 stub(存根) 文件,此后游戏启动时,根据拉取到的游戏配置中的补丁列表 一一查找本地存根,对于已有存根的直接跳过即可。
// 写入存根
void RubbishGamePatchManager::markPatchAsSuccess(const std::string name) {
std::string path = StringUtils::format("%s/%s/active_stub", RMFileUtil::getPatchesDir().c_str(), name.c_str());
bool success = FileUtils::getInstance()->writeStringToFile("success", path);
if (success) {
SLogInfo("patch %s success", name.c_str());
}
}
// 读取存根
bool RubbishGamePatchManager::hasPatchNamed(const std::string name) {
std::string path = StringUtils::format("%s/%s/active_stub", RMFileUtil::getPatchesDir().c_str(), name.c_str());
return FileUtils::getInstance()->isFileExist(path);
}
复制代码
安全模式
人难免会犯错,比如下发了一个有问题的补丁,导致游戏 Crash,为了应对这类问题 Crash,我们可以对资源加载失败和连续 Crash 的情况进行记录,游戏启动时优先检查是否有此类问题,有则删除所有补丁和远程配置,来实现临时的问题止血。
内存数据保护
相信很多玩家都听说 CheatEngine 和 八门神器 等内存数据修改神器,他们有一个门槛很低、功能很强大的功能,那就是定位和修改内存中的值,例如某小白玩一款单机 RPG 游戏,他想要修改自己的金币,他可以进行如下的操作:
- 首先小白看到自己的金币数量是 2000,他在内存中搜索,发现找到了几十万个数值为 2000 的内存单元;
- 随后他花掉了一些金币,这时候金币变成 1850,他在第一次的基础上继续搜索 1850,这次结果的范围缩小到了 1000 条,他继续这样操作,最后定位到了 1 个内存单元;
- 小白将这个值修改为 100 万,然后随便触发一下消耗或者获得金币的操作,由于游戏会先从内存里去读金币,会先读到 100 万,然后在这个基础上操作,于是小白就刷出了很多很多金币。
应对这类情况,一般有两种方式,第一种方式是不信任内存中的值,每次写入数值时,都写入一个非内存的空间,例如数据库或本地文件,读取时也是从非内存的空间读取,这种方式的缺点在于性能不好,不适合频繁的数据操作;第二种方式是对内存中的数据进行加密,笔者非常推荐第二种方式,不仅性能优异,而且还能有效的防止小白修改内存。
这里的加密可以采用简单的异或,因为异或有一个很好的特性,异或两次同样的 key 将得到原来的值:
> xorKey
12345
> 2000 ^ xorKey
14313
> 14313 ^ xorKey
2000
复制代码
利用这个特性,只要在安全数字类构造时先随机生成一个 xorKey,然后在每次存入数据时,先异或一下 key 再存入,读取时再异或一下 key,即可简单的实现内存保护,有效防止小白用户修改。
class SecurityNumber {
private:
long memInteger;
public:
SecurityNumber();
~SecurityNumber();
void setInt(int val);
void setLong(long val);
int getInt();
long getLong();
}
复制代码
// 在构造 Number 时随机生成异或 key
SecurityNumber::SecurityNumber() {
key = random();
setLong(0);
}
void SecurityNumber::setInt(int val) {
memInteger = val ^ key;
}
void SecurityNumber::setLong(long val) {
memInteger = val ^ key;
}
int SecurityNumber::getInt() {
return (int)(memInteger ^ key);
}
long SecurityNumber::getLong() {
return memInteger ^ key;
}
复制代码
为了能让使用者像使用普通的数值类型一样无感知的使用 SecurityNumber,可以重载各种运算符,使得 SecurityNumber 可以和 int、long、float 等正常运算。
总结
在这款游戏的开发过程中,我和我的小伙伴付出了很大心血,也得到了一些成长,现在将这些经验分享给大家,希望能对大家有所帮助。
我们的游戏已上架 App Store,打开 App Store 搜索 垃圾分类王 即可下载,或者点击这里直接跳转到 App Store 下载,欢迎大家下载,祝大家游戏愉快。