垃圾分类王 - 一款魔性小游戏的开发历程

前言

身在杭州,看着上海垃圾分类如火如荼的进行,内心不免有些慌乱,为了更好、更有趣的学习垃圾分类知识,我和小伙伴利用业余时间开发了一款垃圾分类游戏,我们首先确定了基调,游戏要有魔性的画风、粗糙的风格,但粗中有细,简单有趣,又富有挑战性,下面是游戏的预览图和视频。

游戏视频: 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 游戏,他想要修改自己的金币,他可以进行如下的操作:

  1. 首先小白看到自己的金币数量是 2000,他在内存中搜索,发现找到了几十万个数值为 2000 的内存单元;
  2. 随后他花掉了一些金币,这时候金币变成 1850,他在第一次的基础上继续搜索 1850,这次结果的范围缩小到了 1000 条,他继续这样操作,最后定位到了 1 个内存单元;
  3. 小白将这个值修改为 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 下载,欢迎大家下载,祝大家游戏愉快。

猜你喜欢

转载自juejin.im/post/5d4c0866e51d4561b416d427