V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
Game Engines
Unreal Engine
MyCryENGINE
LeanCloud
V2EX  ›  游戏开发

使用云服务快速开发抢蛋糕小游戏的经验谈(附防作弊机制)

  •  
  •   LeanCloud · 2018-12-19 17:15:53 +08:00 · 2962 次点击
    这是一个创建于 2151 天前的主题,其中的信息可能已经有所发展或是发生改变。

    LeanCloud 小游戏开发系列直播新一期即将开始,本期内容:「如何通过服务端控制游戏逻辑」,点击了解更多详情,加入百位游戏开发者群。

    我们在 LeanCloud 成立五周年之际,发布了一款名为《 LeanCloud 周年游戏》的微信小游戏。一方面我们希望通过玩游戏赢奖品的方式来回馈那些一直关注我们的用户,另一方面我们还希望通过一个实际的项目来讲明白 LeanCloud Play 在游戏开发方面的专长。

    《 LeanCloud 周年游戏》玩起来很简单,参与者要在 15 秒内从迅速掉落的蛋糕和炸弹中点中尽可能多的蛋糕来得分,蛋糕有好几种,分值也不一样,而误点到炸弹就要扣分。游戏一结束参与者能在排行榜中看到自己的名次,我们给前 50 名都设置了奖品。

    游戏截图:

    239

    排行榜截图:

    240

    没玩过的朋友可以搜索「 LeanCloud 周年游戏」体验一下。

    这个项目开发周期大概为一周,包含客户端开发 2 天 + 服务端 1 天 + 调试 2 天。

    接下来我会从客户端、服务端、作弊检测三方面来梳理关键的技术细节,希望能够为游戏开发者或感兴趣的朋友提供一些思路。

    在开发环境方面,客户端主要使用了 Cocos Creator 来编辑构建「微信小游戏」项目,服务端使用了 LeanCloud 的云存储、云引擎和排行榜等服务,这些我都会在后面详细介绍。

    客户端

    先说引擎和编辑器。选取 Cocos Creator 的原因是当在编辑器中构建不同平台项目时,它的友好程度一直都比较好,而且 LeanCloud 也为 Cocos Creator 做了适配。我们游戏的玩法比较简单,无需过多解释,所以接下来我会从客户端资源、状态机、暂停、LeanCloud SDK 和微信这些方面来展开描述。

    资源

    在游戏运行过程中,加载资源、实例化、销毁 Node 等任何耗时操作都可能造成游戏卡顿,影响体验,特别是在低端机器上这种现象会更加明显。所以我们应该对资源进行预加载或者预实例化。

    对于加载资源,通常是在场景切换时,对旧场景资源进行卸载,并对新场景资源进行预加载。

    在 Cocos 中,通过 cc.loader 可以很方便地对单个资源、资源列表和资源目录进行加载和缓存。而对于 Node 的实例化和销毁,则要根据 Node 的生命周期进行区分。如果频繁生成和销毁的 Node,我们可以在加载阶段通过对象池技术预先实例化一部分,这样当在游戏过程中需要实例化 Node 时,就不需要实例化,而是从对象池中获取;在不需要时,不进行销毁操作,而是放回至对象池中等待下次使用。如弹幕游戏中的飞机和子弹等。在我们的游戏中,我们也对生成的蛋糕应用了「对象池」技术来避免游戏中可能出现的卡顿。庆幸的是,Cocos 已经提供了这项功能

    状态机

    在游戏运行过程中,游戏主体(或角色)都会有很多的状态,比如英雄的空闲、移动、攻击、死亡等,因此通常会引入「状态机」模式对游戏对象进行设计。我们为抢蛋糕游戏引入了 machina 库作为状态机的框架,将整个游戏主体划分为初始化、准备、进行中、结束四个状态。

    通过状态机,我们可以更加清楚地跟踪游戏在过程中的变化,并可以通过事件在不同的状态下做出不同的处理。

    暂停

    在游戏过程中,我们经常会需要暂停游戏,比如在抢蛋糕游戏结束时不再生成新的蛋糕和位置移动。

    不同的游戏引擎的暂停方式有所不同。通过 Cocos 的文档,我们找到了引擎提供的 cc.director.pause() / cc.director.resume() 接口,但是尝试之后发现很多局限性,比如在暂停之后 Widget 适配会不起作用,ScrollView 拖拽不回弹等情况。

    于是我们决定通过 Component.update(dt) 生命周期和状态机在游戏中自行控制 Node 的更新。主要思路是在全局游戏的 update() 生命周期里,将更新事件交由状态机,只有在游戏进入「进行中」状态时才处理更新事件,而在其他状态下则忽略更新事件。

    更新过程为先获取场景下的所有 CakeCtrl 对象,调用自定义 onUpdate(dt) 方法进行更新(注意不是 update(dt) 生命周期方法)。

    // 游戏状态:
    play: {
       ...
        update: function (dt) {
            const cakeCtrls = this._scene.getComponentsInChildren(CakeCtrl);
            cakeCtrls.forEach((cakeCtrl) => {
                cakeCtrl.onUpdate(dt);
            });
        }
       ...
    },
    

    LeanCloud SDK

    LeanCloud SDK 在大部分平台都做了适配,可以很方便地接入 LeanCloud 云服务。

    开发者在使用 Cocos Creator 时一般在浏览器进行调试开发,当完成后再发布到微信环境。但不同环境下 LeanCloud SDK 略有不同,为了方便使用,你可以通过封装来隐藏加载不同版本 SDK 的细节。

    比如在浏览器环境下,加载 leancloud-storage 库;而发布在微信小游戏环境下,则加载 leancloud-storage/dist/av-weapp-min.js 库。

    if (cc.sys.browserType === cc.sys.BROWSER_TYPE_WECHAT_GAME) {
      AV = require("leancloud-storage/dist/av-weapp-min.js");
    } else {
      AV = require("leancloud-storage");
    }
    

    另外,如果我们需要使用微信授权登录,为了方便在浏览器下调试,我们也可以将 login() 封装成不同的实现,统一逻辑层调用。

    比如在浏览器环境下,使用账号 + 密码方式登录;而在微信小游戏环境下,使用微信授权登录。

    login() {
        return new Promise((resolve, reject) => {
          // 微信登录
          if (cc.sys.browserType === cc.sys.BROWSER_TYPE_WECHAT_GAME) {
            AV.User.loginWithWeapp()
              .then(user => {
                ...
              })
              .catch(error => {
                reject(error);
              });
          } else {
            // 使用默认账号登录,开发调试使用
            AV.User.logIn("1if7jp52qx9771hllat1rvfqt", "123")
              .then(user => {
                ...
              })
              .catch(error => {
                reject(error);
              });
          }
        });
      },
    

    更多关于 SDK 的文档,请访问 LeanCloud 文档

    微信

    因为我们的游戏在排行榜中需要获取玩家的头像和昵称,所以需要使用到微信的获取用户信息(昵称、头像)接口。这里要吐槽一下,微信新版的 SDK 已经不允许用「弹框授权」来直接获取信息了,而需要使用「固定类型的」微信小程序按钮获取。但是这一机制有对微信旧版 SDK 又不可用,所以我们需要根据微信版本,确定通过哪种机制拿到微信授权。

    如果是旧版本的微信,则可以直接调用获取用户信息接口;而如果是新版本的微信,则需要渲染出微信授权按钮,通过按钮的点击事件再获取。这里你可能需要面对小程序的渲染和 Cocos 的渲染机制不一致的问题。

    所以,这里还用到了一个小窍门——将微信小程序的授权按钮设置为透明,覆盖到 Cocos 场景中的按钮之上,当按钮被点击时,系统会先将点击事件传递到微信小程序,在微信小程序回调中处理完成之后再交由游戏中处理。

    服务端

    在服务端开发中,我们主要使用了 LeanCloud 的云存储、云引擎和排行榜服务。

    存储

    在存储方面,主要使用了 3 张表:

    • _User:存储用户信息,LeanCloud 内置表。
    • UserInfo:存储用户的详细信息,用于邮寄奖励。
    • Game:存储玩家每局游戏的数据。

    为了保证游戏安全,只有用户信息是通过 LeanCloud 存储 SDK 直接操作的。而游戏相关的数据,都是通过 LeanCloud SDK 请求到云引擎中处理后保存的。参考文档

    云引擎

    云引擎是 LeanCloud 推出的服务端托管平台。通常比较关键的数据,我们推荐不要使用 SDK 直接操作,而是通过云引擎进行操作。参考文档

    在抢蛋糕游戏中,为了保证游戏安全,我们在游戏结束后并没有在客户端直接通过 LeanCloud SDK 上传分数到排行榜,而是将游戏参数发送到云引擎,通过云引擎分析后再确定是否写入到排行榜。具体流程:

    • 游戏开始,向服务端请求游戏数据,服务端会返回本局游戏的 id 和蛋糕数据;而对于「多次」作弊的玩家,将不返回游戏数据。
    • 游戏结束,客户端将本局游戏的参数提交给服务端,包括:本局游戏 id、分数、蛋糕点击数量、时间戳、签名、蛋糕点击索引序列。
    • 服务端对游戏数据进行合法性检测,如果通过则更新排行榜,否则丢弃并标记用户作弊(作弊检测方法会在后面有详细介绍)。

    排行榜

    排行榜是 LeanCloud Play 为游戏开发者提供的一项新的服务。它除了能提供方便的数据更新接口,还提供了排行榜成绩更新、榜单管理等配置。参考文档

    在抢蛋糕游戏中,除了使用常规的「更新玩家成绩」之外,还用到了对作弊玩家进行「榜单移除」的操作。

    更新玩家成绩

    ...
    // 提交分数
    scoreInLeaderBoard = calcScoreInLeaderBoard(score);
        AV.Leaderboard.updateStatistics(currentUser, {
            free: scoreInLeaderBoard
    })
    

    标记作弊玩家,并移除榜单成绩

    /**
     * 标记用户作弊
     * @param {*} user 用户
     */
    function markUser(user) {
      let cheat = user.get("cheat") ? user.get("cheat") : 0;
      console.log(`cheat: ${cheat}`);
      cheat += 1;
      user.set("cheat", cheat);
      user.save();
      if (cheat > MAX_CHEAT_COUNT) {
        // 如果超过最大作弊次数,则清除榜单
        AV.Leaderboard.deleteStatistics(user, ["score"])
          .then(() => {
            console.log(`remove ${user} statistics`);
          })
          .catch(console.error);
      }
    }
    

    作弊检测

    对于面向程序员制作的游戏,我们猜测到大家可能会通过技术手段来获取更高分数。为了增加大家破解的趣味性,我们也提供了一些作弊检测机制供大家突破——通过运行时作弊检测和离线数据分析生成了最终的榜单数据。

    运行时作弊检测

    具体过程:

    • 在游戏开始时,客户端向服务端发起开始请求,服务端随机生成本局游戏的蛋糕序列(共 200 个,游戏频率为每 0.1s 生成 1 个),将当前时间戳、用户、蛋糕序列保存至 Game 对象。
    • 将 Game 对象 id 和蛋糕序列下发至客户端,客户端根据蛋糕序列生成蛋糕,在游戏过程中,记录玩家点击蛋糕索引。
    • 游戏结束后,将 Game 对象 id、分数、每种蛋糕点击的数量、结束时间戳、签名( md5(id + score + timestamp))和蛋糕点击索引序列发送给服务端。

    服务端接收到参数后,对数据进行校验。

    校验包括:

    • 提交参数是否完整(基础检测)
    • 分数和蛋糕点击数量是否匹配(逻辑检测)
    • 分数和蛋糕索引是否匹配(逻辑检测)
    • 服务端重新计算签名是否匹配(防止修改明文参数) 验证游戏时长是否合理(超过 2 倍游戏时长,则认为玩家可能是在分析请求)
    • 对于检测到作弊的玩家,本局游戏成绩将不会更新排行榜,并记录 1 次作弊,超过 10 次作弊的玩家,将不能请求到游戏开始时的数据。

    离线数据分析

    运行时作弊检测并不足以抵挡住广大开发者破解的热情,很快就有用户梳理清楚了协议参数。所以在运行时检测后,我们又默默记下了用户的参数,用于离线分析。

    校验包括:

    • 验证游戏时长是否小于 1 倍游戏时长(游戏至少需要 18 秒完成,15 秒游戏 + 3 秒倒计时,有些同学竟然 2 秒就把游戏结束请求发来了)
    • 蛋糕点击索引是否有重复(逻辑判断,有些 Android 插件可以让游戏卡住,使同一个蛋糕被点击 N 次,则会叠加多次分数)
    • 蛋糕点击索引是否超过允许最大值(排行榜中有位 500+ 分的朋友通过解包,分析协议,通过模拟请求,顺利通过了上述检测,但是竟然在请求中把 200 个蛋糕索引全部赋值了,而正常游戏中最多只能点击到 150 个,即 15 秒 x 每秒 10 个)

    致命缺陷

    这类游戏是没办法防住按键(触摸)精灵的。如果通过「图像识别 + 自动点击脚本」可以轻松点击完所有的蛋糕并有效避开炸弹,则可以通过上述检测。

    有人说如果服务端运算可不可以,思路是屏幕点击的坐标交由服务端运算,但是对于按键精灵类的脚本还是无法避免,并且还会增加项目的开发量(服务端要对不同分辨率和坐标做一些处理)。如果其他同学有办法做更有效的检测,希望能反馈到 LeanCloud 论坛,大家共同讨论。

    以上便是我们使用自己的产品来开发游戏的心得体会,希望能对大家有所帮助。

    1 条回复    2018-12-20 09:35:06 +08:00
    009694
        1
    009694  
       2018-12-20 09:35:06 +08:00 via iPhone
    那个 md5 签名感觉很多余。。。那不能叫签名 只能叫校验。。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   961 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 22:18 · PVG 06:18 · LAX 14:18 · JFK 17:18
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.