回到目录

用机械臂来玩微信跳一跳小游戏

2018-01-06

该文被同步转载于腾讯 WeTest 公众号,获得了 15w+ 的点击率,内容有微调:揭密微信跳一跳小游戏那些外挂

随着微信小游戏的推出,网页游戏仿佛又再一次焕发生机,加上好友排行榜的所带来的虚荣感,谁不希望拿到一个高分数。然而让我更感兴趣的是开源世界里程序猿们的「自动化尝试」,你们可以说这是作弊,但你们关注的游戏本身,而程序猿们更关注的是用技术挑战规则,突破极限的快感,恰好今年机器学习的概念和实践逐渐走向普罗大众,也让这件事情变得更加有未来感。

作为程序猿里稍微有一点动手能力的人,通过之前在开源硬件里的实践,我打算摒弃传统的通过模拟器发送指令按压屏幕的方法,而用物理机械臂来代替人的手指来点按屏幕实现跳一跳,这样做的好处是更符合人类操作习惯,也让原本只有前端开发、客户端开发、AI 工程师参与的社会化编程游戏,增加了硬件的元素在里边。当然我也不是第一个尝试硬件来跳一跳的人,这件事情本身的意义就在于动手实践。

0x1 梳理

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

1、距离判断:

  • 简单方案:像素点判断
  • 进阶方案:OpenCV 图像分析
  • 高阶方案:深度学习

2、按压模拟:

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

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

0x2 距离判断

1. 像素点判断

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

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

相关代码:

# 以 50px 步长,尝试探测 scan_start_y
for i in range(int(h / 3), int(h*2 / 3), 50):
    last_pixel = im_pixel[0, i]
    for j in range(1, w):
        pixel = im_pixel[j, i]
        # 不是纯色的线,则记录 scan_start_y 的值,准备跳出循环
        if pixel != last_pixel:
            scan_start_y = i - 50
            break
    if scan_start_y:
        break
print('scan_start_y: {}'.format(scan_start_y))

# 从 scan_start_y 开始往下扫描,棋子应位于屏幕上半部分,这里暂定不超过 2/3
for i in range(scan_start_y, int(h * 2 / 3)):
    # 横坐标方面也减少了一部分扫描开销
    for j in range(scan_x_border, w - scan_x_border):
        pixel = im_pixel[j, i]
        # 根据棋子的最低行的颜色判断,找最后一行那些点的平均值,这个颜
        # 色这样应该 OK,暂时不提出来
        if (50 < pixel[0] < 60) and (53 < pixel[1] < 63) and (95 < pixel[2] < 110):
            piece_x_sum += j
            piece_x_c += 1
            piece_y_max = max(i, piece_y_max)

if not all((piece_x_sum, piece_x_c)):
    return 0, 0, 0, 0
piece_x = int(piece_x_sum / piece_x_c)
piece_y = piece_y_max - piece_base_height_1_2  # 上移棋子底盘高度的一半

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

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

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

相关代码:

# 限制棋盘扫描的横坐标,避免音符 bug
if piece_x < w/2:
    board_x_start = piece_x
    board_x_end = w
else:
    board_x_start = 0
    board_x_end = piece_x

for i in range(int(h / 3), int(h * 2 / 3)):
    last_pixel = im_pixel[0, i]
    if board_x or board_y:
        break
    board_x_sum = 0
    board_x_c = 0

    for j in range(int(board_x_start), int(board_x_end)):
        pixel = im_pixel[j, i]
        # 修掉脑袋比下一个小格子还高的情况的 bug
        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:
            board_x_sum += j
            board_x_c += 1
    if board_x_sum:
        board_x = board_x_sum / board_x_c
last_pixel = im_pixel[board_x, i]

# 从上顶点往下 +274 的位置开始向上找颜色与上顶点一样的点,为下顶点
# 该方法对所有纯色平面和部分非纯色平面有效,对高尔夫草坪面、木纹桌面、
# 药瓶和非菱形的碟机(好像是)会判断错误
for k in range(i+274, i, -1):  # 274 取开局时最大的方块的上下顶点距离
    pixel = im_pixel[board_x, k]
    if abs(pixel[0] - last_pixel[0]) \
            + abs(pixel[1] - last_pixel[1]) \
            + abs(pixel[2] - last_pixel[2]) < 10:
        break
board_y = int((i+k) / 2)

# 如果上一跳命中中间,则下个目标中心会出现 r245 g245 b245 的点,利用这个
# 属性弥补上一段代码可能存在的判断错误
# 若上一跳由于某种原因没有跳到正中间,而下一跳恰好有无法正确识别花纹,则有
# 可能游戏失败,由于花纹面积通常比较大,失败概率较低
for j in range(i, i+200):
    pixel = im_pixel[board_x, j]
    if abs(pixel[0] - 245) + abs(pixel[1] - 245) + abs(pixel[2] - 245) == 0:
        board_y = j + 10
        break

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

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

2. OpenCV 图像分析

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

相关代码:

# imread()函数读取目标图片和模板
img_rgb = cv2.imread("0.png", 0)
template = cv2.imread('temp1.jpg', 0) 

# matchTemplate 函数:在模板和输入图像之间寻找匹配,获得匹配结果图像 
# minMaxLoc 函数:在给定的矩阵中寻找最大和最小值,并给出它们的位置
res = cv2.matchTemplate(img_rgb,template,cv2.TM_CCOEFF_NORMED)
min_val,max_val,min_loc,max_loc = cv2.minMaxLoc(res)
center1_loc = (max_loc1[0] + 39, max_loc1[1] + 189)

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

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

相关代码:

# 先尝试匹配截图中的中心原点,
# 如果匹配值没有达到0.95,则使用边缘检测匹配物块上沿
res2 = cv2.matchTemplate(img_rgb, temp_white_circle, cv2.TM_CCOEFF_NORMED)
min_val2, max_val2, min_loc2, max_loc2 = cv2.minMaxLoc(res2)
if max_val2 > 0.95:
    print('found white circle!')
    x_center, y_center = max_loc2[0] + w2 // 2, max_loc2[1] + h2 // 2
else:
    # 边缘检测
    img_rgb = cv2.GaussianBlur(img_rgb, (5, 5), 0)
    canny_img = cv2.Canny(img_rgb, 1, 10)
    H, W = canny_img.shape

    # 消去小跳棋轮廓对边缘检测结果的干扰
    for k in range(max_loc1[1] - 10, max_loc1[1] + 189):
        for b in range(max_loc1[0] - 10, max_loc1[0] + 100):
            canny_img[k][b] = 0

    img_rgb, x_center, y_center = get_center(canny_img)
    
def get_center(img_canny, ):
    # 利用边缘检测的结果寻找物块的上沿和下沿
    # 进而计算物块的中心点
    y_top = np.nonzero([max(row) for row in img_canny[400:]])[0][0] + 400
    x_top = int(np.mean(np.nonzero(canny_img[y_top])))

    y_bottom = y_top + 50
    for row in range(y_bottom, H):
        if canny_img[row, x_top] != 0:
            y_bottom = row
            break

    x_center, y_center = x_top, (y_top + y_bottom) // 2
    return img_canny, x_center, y_center

3. 深度学习

通过深度学习来提升识别的准确率,这块我暂时 hold 不住,所以先不细讲,可查看下面两篇文章了解详情:一次不成功的深度学习实践 - 微信跳一跳自动玩微信小游戏跳一跳

0x3 按压模拟

1. adb/wda 指令

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

# Android 屏幕截图
adb shell screencap -p /sdcard/autojump.png
adb pull /sdcard/autojump.png .

# Andrid 屏幕按压模拟
adb shell input swipe x y x y time(ms)

# iOS 屏幕截图
wda.Client().session().screenshot('1.png')

# iOS 屏幕按压模拟
wda.Client().session().tap_hold(200, 200, press_time)

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

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


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

2. 机械臂模拟手指点击(原创)

由于我喜欢折腾过开源硬件(可重温下之前的一个实践 用 Siri 控制你的电器),所以也想给跳一跳小游戏增加一些有趣的动手环节,把触摸模拟这一操作通过机械臂来物理完成,于是在万能淘宝里淘了一个一百多快钱的机械臂和部分配件,自己编写了控制代码,把按压时间传输作为机械臂按下的停留时间,想法确定后便开始购置物品:

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

0x4 防 Ban 处理:

由于防作弊并非这次研究的本意,所以简单带过:

1.按下和抬起位置随机

正常的人类操作,每次按下屏幕的位置必然不是百分百在同一个点上,所以按下的位置需要随机,同理抬起手指的过程也是会有部分位移的,所以按下和抬起点不应该一样。

global swipe_x1, swipe_y1, swipe_x2, swipe_y2
w, h = im.size
left = int(w / 2)
top = int(1584 * (h / 1920.0))
left = int(random.uniform(left-50, left+50))
top = int(random.uniform(top-10, top+10))    # 随机防 ban
#...
cmd = 'adb shell input swipe {x1} {y1} {x2} {y2} {duration}'.format(
    x1=swipe_x1,
    y1=swipe_y1,
    x2=swipe_x2,
    y2=swipe_y2,
    duration=press_time
)

2. 每次按压动作时间间隔随机

同理,每次按压的时间间隔必然不是相等的,所以每次循环的 delay/sleep 时间也要随机:

# 为了保证截图的时候应落稳了,多延迟一会儿,随机值防 ban
time.sleep(random.uniform(0.9, 1.2))

0x5 最后

正如开篇所说:你们关注的游戏是否作弊,而程序猿们更关注的是用技术挑战规则,突破极限的快感。通过对这些「辅助程序」的源码解读,可以学习到非常多的创新思考在里面,同时自己再添加多一些想法衍生出更有意思的内容,这是一个良性的发展。