五天完成小游戏开发经验总结(附防作弊机制)

发表于2018-12-19
评论6 1.02w浏览

我们在 LeanCloud 成立五周年之际,发布了一款名为《LeanCloud 周年游戏》的微信小游戏。

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

游戏截图:

排行榜截图:

没玩过的朋友可以搜索「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 已经不允许用「弹框授权」来直接获取信息了,而需要使用「固定类型的」微信小程序按钮获取。但是这一机制有对微信旧版 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 论坛,大家共同讨论。

以上便是我们此次开发小游戏的心得体会,希望能对大家有所帮助。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引