走进跳一跳小游戏外挂

1516041584_81_w1080_h812-1

张小龙:这个游戏发布以后,其实它的效果有点超出我们的预期,我们自己开玩笑说,这个游戏突然变成了有史以来可能用户规模最大的一个游戏,因为它的DAU大概到了1点几亿,但同时出现了很多外挂,我没有想到这么小的一款游戏也会有那么多外挂,我朋友圈的朋友也打出了特别高的分,但是我相信不是他自己打出来的。

1 月 15 日微信在广州召开 2018 年的微信公开课 Pro,张小龙不仅当场玩了一把跳一跳游戏并取得了900多的高分,还特别提到了跳一跳小游戏的外挂:

在跳一跳这样一个小的游戏里面,如果一个用户看到里面有一堆外挂得了很高的分,对其他一些每天在练习,试图把自己的水平提高,而打一个高分的人就很不公平,他可能就没有动力继续去练习,继续超越自己个人的最高分数。所以这样一个行为,外挂行为其实会破坏整个系统的规则,并且让规则立即变得失效。

所以,我们这个小游戏发布以后,我们就开始花了很多很多时间来打击外挂。

本着钻研技术的学习态度,我对目前几款比较火的外挂进行了源码分析,总结出了它们的一些破解思路,其实这些作者都并非恶意,作为一个程序员,还有什么比用技术挑战规则,突破极限要有成就感呢?

0x1 梳理

跳一跳的游戏可以细分为两步骤:距离判断 + 按压模拟,这两步都有下面这些解决方案:
1、距离判断:

  • 简单方案:像素点判断
  • 进阶方案:OpenCV 图像分析

2、按压模拟:

  • 简单方案:adb/wda 指令
  • 进阶方案:机械臂模拟手指点击(原创)

下面逐一介绍这里的实现方法,非常有意思:

0x2 距离判断

1. 像素点判断

该方法采用自目前最火的跳一跳小游戏「辅助程序」:wechat_jump_game

1516041584_81_w1080_h812

如上图所示,我们先定义了「棋子」和「棋盘」,需要找到的两个目标点用橙色点标注,首先针对棋子的目标点的判断,可以这么做:

1516041600_13_w1558_h1009

相关代码:

  1.    # 以 50px 步长,尝试探测 scan_start_y

  2. for i in range(int(h / 3), int(h*2 / 3), 50):

  3. last_pixel = im_pixel[0, i]

  4. for j in range(1, w):

  5. pixel = im_pixel[j, i]

  6. # 不是纯色的线,则记录 scan_start_y 的值,准备跳出循环

  7. if pixel != last_pixel:

  8. scan_start_y = i - 50

  9. break

  10. if scan_start_y:

  11. break

  12. print('scan_start_y: {}'.format(scan_start_y))

  13. # 从 scan_start_y 开始往下扫描,棋子应位于屏幕上半部分,这里暂定不超过 2/3

  14. for i in range(scan_start_y, int(h * 2 / 3)):

  15. # 横坐标方面也减少了一部分扫描开销

  16. for j in range(scan_x_border, w - scan_x_border):

  17. pixel = im_pixel[j, i]

  18. # 根据棋子的最低行的颜色判断,找最后一行那些点的平均值,这个颜

  19. # 色这样应该 OK,暂时不提出来

  20. if (50 < pixel[0] < 60) and (53 < pixel[1] < 63) and (95 < pixel[2] < 110):

  21. piece_x_sum += j

  22. piece_x_c += 1

  23. piece_y_max = max(i, piece_y_max)

  24. if not all((piece_x_sum, piece_x_c)):

  25. return 0, 0, 0, 0

  26. piece_x = int(piece_x_sum / piece_x_c)

  27. piece_y = piece_y_max - piece_base_height_1_2 # 上移棋子底盘高度的一半


而针对棋盘中心点的确认的思路则是这样的:

当然还有一些其他方法来尽量缩小棋盘中心点的检测区域,这里简单介绍下:

1516106278_64_w1558_h1009

当然,如果恰好跳到中心点,下一个棋盘中间会有白色点,则可以直接匹配中心点的色值,得到棋盘中心点,这种情况基本百发百中:

1516041699_94_w1558_h1009

相关代码:

  1.    # 限制棋盘扫描的横坐标,避免音符 bug

  2. if piece_x < w/2:

  3. board_x_start = piece_x

  4. board_x_end = w

  5. else:

  6. board_x_start = 0

  7. board_x_end = piece_x

  8. for i in range(int(h / 3), int(h * 2 / 3)):

  9. last_pixel = im_pixel[0, i]

  10. if board_x or board_y:

  11. break

  12. board_x_sum = 0

  13. board_x_c = 0

  14. for j in range(int(board_x_start), int(board_x_end)):

  15. pixel = im_pixel[j, i]

  16. # 修掉脑袋比下一个小格子还高的情况的 bug

  17. if abs(j - piece_x) < piece_body_width: continue # 修掉圆顶的时候一条线导致的小 bug,这个颜色判断应该 OK,暂时不提出来 if abs(pixel[0] - last_pixel[0]) \ + abs(pixel[1] - last_pixel[1]) \ + abs(pixel[2] - last_pixel[2]) > 10:

  18. board_x_sum += j

  19. board_x_c += 1

  20. if board_x_sum:

  21. board_x = board_x_sum / board_x_c

  22. last_pixel = im_pixel[board_x, i]

  23. # 从上顶点往下 +274 的位置开始向上找颜色与上顶点一样的点,为下顶点

  24. # 该方法对所有纯色平面和部分非纯色平面有效,对高尔夫草坪面、木纹桌面、

  25. # 药瓶和非菱形的碟机(好像是)会判断错误

  26. for k in range(i+274, i, -1): # 274 取开局时最大的方块的上下顶点距离

  27. pixel = im_pixel[board_x, k]

  28. if abs(pixel[0] - last_pixel[0]) \

  29. + abs(pixel[1] - last_pixel[1]) \

  30. + abs(pixel[2] - last_pixel[2]) < 10:

  31. break

  32. board_y = int((i+k) / 2)

  33. # 如果上一跳命中中间,则下个目标中心会出现 r245 g245 b245 的点,利用这个

  34. # 属性弥补上一段代码可能存在的判断错误

  35. # 若上一跳由于某种原因没有跳到正中间,而下一跳恰好有无法正确识别花纹,则有

  36. # 可能游戏失败,由于花纹面积通常比较大,失败概率较低

  37. for j in range(i, i+200):

  38. pixel = im_pixel[board_x, j]

  39. if abs(pixel[0] - 245) + abs(pixel[1] - 245) + abs(pixel[2] - 245) == 0:

  40. board_y = j + 10

  41. break


但棋盘种类比较多,形状也各异,而且棋盘表面并非纯色,还有其他颜色,所以即使像素判断的代码里增加了很多特殊 case,依旧不能做到非常完美:

1516041746_82_w1558_h1009

 

总结一下,目前这个方案基本没有太大问题,但如果跳一跳游戏把背景改成了非线性渐变,或随机飘落一些物体,或棋盘表面更加复杂,那这里的算法就基本不可用了。

2. OpenCV 图像分析

基于像素点的判断低效而且不够健壮,而利用 OpenCV 计算机视觉库则可以从图像分析层面进一步简化判断逻辑提升效率,首先采用该方法的跳一跳小游戏「辅助程序」来自 wechat_jump_jump。它是这么得到棋子的位置的:

1516041771_60_w1558_h1009

相关代码:

  1. # imread()函数读取目标图片和模板

  2. img_rgb = cv2.imread("0.png", 0)

  3. template = cv2.imread('temp1.jpg', 0)

  4. # matchTemplate 函数:在模板和输入图像之间寻找匹配,获得匹配结果图像

  5. # minMaxLoc 函数:在给定的矩阵中寻找最大和最小值,并给出它们的位置

  6. res = cv2.matchTemplate(img_rgb,template,cv2.TM_CCOEFF_NORMED)

  7. min_val,max_val,min_loc,max_loc = cv2.minMaxLoc(res)

  8. center1_loc = (max_loc1[0] + 39, max_loc1[1] + 189)

接下来找棋盘的中心点,假如下一个棋盘存在白色的示意点,同样采用上面的模板匹配方法进行匹配,若匹配不上(匹配值小于某阈值,也许下个棋盘本身就是白色,所以灰度图分辨不出),则采用第二种方案:

1516042106_6_w1558_h768

这里是否准确的精髓就在于高斯滤波去除图像噪音的临界点以及 Canny 函数中阈值的设定,需要不断调整参数到最优状态。

相关代码:

  1. # 先尝试匹配截图中的中心原点,

  2. # 如果匹配值没有达到0.95,则使用边缘检测匹配物块上沿

  3. res2 = cv2.matchTemplate(img_rgb, temp_white_circle, cv2.TM_CCOEFF_NORMED)

  4. min_val2, max_val2, min_loc2, max_loc2 = cv2.minMaxLoc(res2)

  5. if max_val2 > 0.95:

  6. print('found white circle!')

  7. x_center, y_center = max_loc2[0] + w2 // 2, max_loc2[1] + h2 // 2

  8. else:

  9. # 边缘检测

  10. img_rgb = cv2.GaussianBlur(img_rgb, (5, 5), 0)

  11. canny_img = cv2.Canny(img_rgb, 1, 10)

  12. H, W = canny_img.shape

  13. # 消去小跳棋轮廓对边缘检测结果的干扰

  14. for k in range(max_loc1[1] - 10, max_loc1[1] + 189):

  15. for b in range(max_loc1[0] - 10, max_loc1[0] + 100):

  16. canny_img[k][b] = 0

  17. img_rgb, x_center, y_center = get_center(canny_img)

  18. def get_center(img_canny, ):

  19. # 利用边缘检测的结果寻找物块的上沿和下沿

  20. # 进而计算物块的中心点

  21. y_top = np.nonzero([max(row) for row in img_canny[400:]])[0][0] + 400

  22. x_top = int(np.mean(np.nonzero(canny_img[y_top])))

  23. y_bottom = y_top + 50

  24. for row in range(y_bottom, H):

  25. if canny_img[row, x_top] != 0:

  26. y_bottom = row

  27. break

  28. x_center, y_center = x_top, (y_top + y_bottom) // 2

  29. return img_canny, x_center, y_center


 

0x3 按压模拟

1. adb/wda 指令

这两个分别是针对 Android 和 iOS 的命令行工具,可以将手机和电脑连接起来,并通过命令行发送指令,指令中就包含了屏幕的截图和按压模拟。不过 iOS 配置起来稍微麻烦一点,具体操作指引可以参考 这里。其核心的命令有:

  1. # Android 屏幕截图

  2. adb shell screencap -p /sdcard/autojump.png

  3. adb pull /sdcard/autojump.png .</p>

  4. # Andrid 屏幕按压模拟

  5. adb shell input swipe x y x y time(ms)

  6. # iOS 屏幕截图

  7. wda.Client().session().screenshot('1.png')

  8. # iOS 屏幕按压模拟

  9. wda.Client().session().tap_hold(200, 200, press_time)


当然,如果嫌配置麻烦,还可以通过 Android 的 AirDrop App 或 iOS 的 QuickTime 把手机屏幕投到电脑中,然后通过 Python 的 Pillow 库来截取投屏的内容,再做进一步的图像识别工作。

还有一点值得一提,按压时间这部分还是有优化的空间,前面提到了跳跃距离和按压时间基本是线性关系,但越到后面可以越发现,距离并非和按压时间绝对成线性比例,因为游戏本身不是一个纯 2D 的平面场景(2.5D),所以我们测量到的直线距离在 2.5D 场景中是有变化的,虽然影响不大,但在游戏后期棋盘越来越小,距离越来越大时,容易凸现出问题来,所以关于距离的计算有几种不同的解决:

拟合函数的细节可以参考:123

2. 机械臂模拟手指点击

本着学术探究的态度,结合之前折腾开源硬件的经历,所以也斗胆想给跳一跳小游戏增加一点动手环节,把触摸模拟这一操作通过机械臂来物理完成,于是在万能淘宝里淘了一个一百多快钱的机械臂和部分配件,自己编写了控制代码,把按压时间传输作为机械臂按下的停留时间,想法确定后便开始购置物品:

1516042282_37_w1558_h503

到货后折腾一两个晚上,最后成功搭建好了,大家看看效果(电容笔偶尔还是会触碰不良):

0x4 最后

反对一切使用外挂行为!
反对一切使用外挂行为!
反对一切使用外挂行为!
通过对外挂程序源码的研读,学习到了非常多创新的思维,这也算是外挂留给代码世界的果实。

发表评论