/ js

用Node.js写一个跳一跳外挂

最近在家养病,正闲的手痒痒,恰好看到腾讯发布了“跳一跳”小游戏,看到朋友圈好多人都玩得起兴,于是我决定换个方式玩一把~

思路

  1. 对游戏画面截图
  2. 识别图像,确定当前位置和目标点位置
  3. 根据距离计算出跳的时间
  4. 进行模拟操作

实现

1. 截图

Android中内置了截图的screencap命令,可以通过adb shell进行调用。

为了方便调用,先对adb做个封装

const ADB_PATH = '/PATH_OF_ANDROID_SDK/platform-tools/adb';

async function adb(shell, ...args) {
  console.log(args.join(' '));
  return await new Promise((resolve, reject) => {
    const process = child_process.spawn(ADB_PATH, [shell, ...args]);
    const outBufs = [];

    process.stdout.on('data', data => {
      outBufs.push(data);
    });

    process.on('close', code => {
      if (code === 0) {
        resolve(Buffer.concat(outBufs));
      } else {
        reject(`Error: ${code}`);
      }
    });
  });
}

截图就直接调用screencap就行了,添加-p的参数表示以png格式输出

async function screenshot() {
  return await adb('shell', 'screencap', '-p');
}

2. 图像识别

图像识别的目的是找出小人和目标方块的位置,以便计算距离。为了简单,我用了一种比较“偷懒”的方法。
jump
从图中可以看出,我们要计算的实际距离是红色线段的长度,由于游戏视角度固定,所以红色线的斜率几乎是不变的(由于小人站的位置并不固定,或左或右,所以角度其实会有微小的变化,不过为简单起见先忽略),所以红色和蓝色线段的比例固定,继而可以简化成计算蓝色线段的长度,即在水平方向上目标盒子中心点和小人中心点的距离。

找中点就比较简单了,只要去掉顶部分数的部分,画面的最高点就是目标盒子的水平方向上的中点。找小人也很容易,小人的头部为60x60(分辨率1920x1080下)的圆形,那么只要根据长宽或者面积筛选出这个轮廓,然后找到轮廓的最高点即可。两个最高点的X坐标相减求绝对值就是要求距离了。

图像识别使用了OpenCV库的Node.js封装node-opencv,功能很强大,只可惜文档写的很差,很多方法都是扒源码和examples找到的。

首先获取截图,然后用OpenCV打开图片:

  const sc = await screenshot();
  const img = await wrapper(cv.readImage, cv, sc);

获取目标盒子中点的X坐标:

function getTargetX(img) {
  img = img.clone();

  // 转化为灰度图,用于边缘检测
  img.cvtColor('CV_BGR2GRAY');

  // 使用canny算法进行边缘检测
  const lowThresh = 2;
  const highThresh = 100;
  img.canny(lowThresh, highThresh);

  // 获取所有轮廓线
  const contours = img.findContours();

  const opt = {
    filter(i) {
      // 有时两个盒子离得特别近,这时小人的头会高过盒子的顶点
      // 简单粗暴,通过面积排除掉小人的头
      const area = contours.area(i);
      return area < 2500 || 2900 < area;
    }
  };

  // 计算顶部点X坐标的平均值
  return getTopPointsAvgX(contours, 400, opt);
}

获取小人中点的X坐标也类似:

function getCurrentX(img, id) {
  img = img.clone();

  // 这里很奇怪,如果不这么转一次的话后面得不到正确的过滤结果
  img.cvtColor('CV_BGR2Lab');
  img.cvtColor('CV_Lab2BGR');

  // 根据颜色过滤出小人
  img.inRange([50, 50, 50], [120, 80, 80]);

  // 获取轮廓线
  const contours = img.findContours();

  const opt = {
    filter(i) {
      // 根据面积,过滤出小人的头
      const area = contours.area(i);
      return 2500 < area && area < 2900;
    }
  };

  // 计算顶部点X坐标的平均值
  return getTopPointsAvgX(contours, 400, opt);
}

这两个方法中都用到了getTopPointsAvgX,这是封装的一个公共方法,作用是计算所有轮廓中最高的一个轮廓中顶点的X坐标(为了精确这里计算了所有最高点X坐标的平均值):

function getTopPointsAvgX(contours, offsetY = 0, opt = {}) {
  // 取出最高的物体,即为目标
  let minTopContour = Infinity;
  let targetIndex = -1;
  for (let i=0; i<contours.size(); i++) {
    const rect = contours.boundingRect(i);

    // 排除距离顶部小于offsetY的物体,例如分数、菜单等附加信息
    // 根据自定义的opt.filter排除其他干扰物
    if (rect.y < offsetY || (opt.filter && !opt.filter(i))) {
      continue;
    }

    if (rect.y < minTopContour) {
      minTopContour = rect.y;
      targetIndex = i;
    }
  }

  // 获取最高物体中的最高点
  const minY = Math.min(...contours.points(targetIndex).map(p => p.y));

  // 取出所有顶部的点
  const topPoints = contours.points(targetIndex).filter(p => p.y === minY);

  // 计算顶部点X坐标的平均值
  const averageX = topPoints.reduce((sum, a) => sum + a.x, 0) / topPoints.length;

  return averageX;
}

把这些轮廓画下来就是这个样子:
jump-outline

计算距离就很简单了:

  const currentPointX = getCurrentX(img);
  const targetPointX = getTargetX(img);

  const length = Math.abs(targetPointX - currentPointX);

3. 计算时间

为了求出小人起跳时长和跳跃距离的关系,首先根据经验设定一些时间值进行试跳,然后用PS计算出试跳的长度,记录下这些值

时长(ms) 距离(px)
913 556
363 194
426 216
780 445
635 372

导入到Numbers中画出散点图,并进行拟合。
jump_fitting-1

得到拟合结果:
t = 1.5089x + 85.333

把上一步求出的距离带入即可:

  const second = Math.round(1.5089 * length + 85.333);

4. 跳

Android内置的input命令提供了对触控和键盘输入的模拟的功能。一般来说,屏幕点击可以通过input touch x y进行模拟,但是这个命令并不能模拟长按,不过我们可以通过另外一个命令input swipe x0 y0 x1 y1 [duration]模拟长按。

顺便提一下,MIUI系统中对“模拟输入”这种危险性较高的命定额外加了一层限制,需要打开“开发者选项”中的“USB调试(安全设置)”才能使用。

顺手加了几个随机数,防止被封:

async function jump(ms) {
  const touchX1 = Math.round(Math.random() * 740 + 100);
  const touchY1 = Math.round(Math.random() * 740 + 220);
  const touchX2 = Math.round(Math.random() * 20 + touchX1);
  const touchY2 = Math.round(Math.random() * 20 + touchY1);
  await adb('shell', 'input', 'swipe', touchX1, touchY1, touchX2, touchY2, Math.round(ms));
}

花了一下午写了这个小东西,试了一下打出了840分,最后一次对目标盒子边缘识别错了,导致直接飞了出去。
代码中还有好多不足:

  1. 边缘识别偶尔会出错,参数还需要微调;
  2. opencv识别的图像次数多了之后有时轮廓识别混杂前几次的结果,原因不明,感觉不在js这儿;
  3. 文中提过,小人的位置计算并不精确;
  4. 如果需要把外挂做成自动连跳,则需要对下水道、超市、魔方含有彩蛋的方块进行识别;
  5. ...

养病去了,不优化了,写出来抛砖引玉。

用Node.js写一个跳一跳外挂
Share this