赤裸裸的来蹭下热点。 微信跳一跳小游戏,风格简约,忍不住动心思自动跳一跳。代码阅读起来太费劲,决定写一篇文章描述一下自己的代码。
仅供练习nodejs技能,勿讨论作弊手段。
最终效果
- 自动蓄力弹跳
辅助线跳一跳:player.youku.com/embed/XMzMw…
自动跳一跳:player.youku.com/embed/XMzMw…
内容
-
使用的开箱即用工具
-
游戏目标分析
-
设备数据(借助别人github repo,非ADB)
- 手机屏幕图像获取,同屏显示
- 手机触摸事件发送
-
图像处理
-
技能点:
- Electron-vue
- Vue directives
- Promise、 async/await
- Nodejs Socket
- koa + websocket
- Opencv4nodejs
使用的开箱即用工具
- Opencv4Nodejs nodejs 调用 opencv 库
- openstf/minicap socket方式安卓设备屏幕截图图像流。Android 5.0 以上,stream输出帧率与设备一致。
- openstf/minitouch 安卓设备 sendevent 替代者,实时性高。
- electron-vue 使用electron直接与socket交互,并使用vue显示屏幕。
游戏目标分析
游戏中,小人蓄力时长决定弹跳距离,成功跳到下一个墩子,即加分。
目标即获取小人位置,获取目标点位置然后计算距离。
在做的过程中,发现,人物弹跳方向为斜向30度,未跳到中心点的情况下,偏移位置似乎不会导致游戏失败。
于是游戏目标简化为搜索小人位置,与搜索墩子中心点横坐标。
墩子中心点横坐标,与墩子顶点横坐标基本一致,只有一个长方形墩子不一致。
小人的圆形头部图像不变,使用opencv模板识别,直接能够准确搜索到人头位置。 所以游戏目标再简化为:
- 求弹跳的时间距离曲线。
- 求小人坐标。
- 求顶点坐标。
设备数据
手机屏幕图像获取,同屏显示
将openstf/minicap
,openstf/minitouch
部署到安卓设备,然后通过adb启动socket,再通过adb连接socket,后续请求与发送数据不需要再次创建adb连接,实时性较好。
启动Socket : /src/renderer/util/adbkit.js#L77
async function startMinicap
:
...
let command = util.format(
'LD_LIBRARY_PATH=%s exec %s %s',
path.dirname('/data/local/tmp/minicap.so'),
'/data/local/tmp/minicap',
`-P 1080x1920@360x640/${orientation} -S -Q ${quality}`
)
// `-P 540x960@360x640/${orientation} -S -Q ${quality}`
status.tryingStart = true
let stdout = await client.shell(device.id, command)
...
复制代码
stdout 为标准输出的socket对象,后续加一个200ms内无错误即resolve的Promise,令startMinicap可正确await。
连接Socket,获取Stream : /src/renderer/util/getStream.js#L6 async function liveStream
:
...
var { err, stream } = await client
.openLocal(device.id, 'localabstract:minicap')
.timeout(10000)
.then(out => ({ stream: out }))
.catch(err => ({ err }))
...
复制代码
获取stream ,然后使用on readable 事件取屏幕每帧图片,格式为jpeg压缩。
...
stream.on('readable', tryRead)
...
复制代码
function tryRead #L50,其逻辑为解析stream每次读取到的buffer,按条件拼成jpeg raw buffer 。
此处可简单做限图像刷新频率处理 #L154
Vue 中使用 canvas 显示buffer图像
显示图像,可以方便的反馈判别结果。
上一步的socket,可以在electron中轻松import,并可以方便的将每一个framebuffer 赋值给 vm.screendata 。 使用vue监听screendata,即可实时将screendata显示到canvas中。
这里用到 vue 的 directives 。
<canvas v-screen='screendata' id='screen' :width="canvasWidth" :height="canvasHeight" :style="canvasStyle"></canvas>
复制代码
...
directives: {
screen(el, binding, vNode) {
// console.info('[canvas Screen]')
if (!binding.value) return
// console.info('render an image ---- ', +new Date())
let BLANK_IMG = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
var g = el.getContext('2d')
var blob = new Blob([binding.value], { type: 'image/jpeg' })
var URL = window.URL || window.webkitURL
var img = new Image()
img.onload = () => {
vNode.context.canvasWidth = img.width
vNode.context.canvasHeight = img.height
g.drawImage(img, 0, 0)
// firstImgLoad = true
img.onload = null
img.src = BLANK_IMG
img = null
u = null
blob = null
}
var u = URL.createObjectURL(blob)
img.src = u
},
...
}
...
复制代码
使用 URL.createObjectURL
为img生成一个src地址,然后将img画到canvas中。 定义 directives
时,vNode
需要手动传入,不能直接用this
。
【
此处,假装一个动态GIF:
stream.on('readable',function tryRead(){
...
framedata = chunk.read()
callback(framedata)
...
})
function callback (framedata){
vm.screendata = framedata
}
每一个framedata 赋给 vm.screendata, Canvas上显示的图像刷新一下。
】
复制代码
代码中同样使用directives
做了一个辅助线层,用来显示辅助线,以及找到的点。
设备触摸事件发送
按照屏幕stream的方式,取得minitouch的socket,对socket按照minitouch README中格式进行write,即可完成触摸事件的模拟。
触摸时长的控制,通过控制touchdown与touchup的时间长度调节。兼容设备触摸事件,设定每超过200ms,进行原地touchmove一下。代码MirrorScreen.vue#L221
时间调节,通过async / await 实现。标准的api应用,似乎没什么可说的。
敲下地面
到此,准备好的工具,能够提供给我截图,画点,精确ms时长蓄力,于是我采集到了一些数据:
X = [0,50,100,150,200,250,300,700,1000]
Y = [0,33, 69, 90,144,177,207,516, 753]
复制代码
得到方程式,准确度非极致,但能够使用了。
f(x) = -6.232e-08 x^3 + 0.0001559 x^2 + 0.6601 x - 0.7638
复制代码
图像处理
首先, open4nodejs 的使用。 opencv4nodejs 的README讲得挺全的。
最开始搜索node版opencv时,发现有2.4版本有3.0版本。这个repo使用的3.0版本,安装起来也很顺利。
README中,不同通道数的图像,根据坐标获取图像的颜色信息,创建一个形状等,描述的都很清楚。
找顶点的方式,想到了使用漫水法填充背景色,然后二值化+反色取到最靠上的顶点。
实际过程中会遇到:
- 小人比新出现的墩子高,或者小人跳到中心出现的波纹和加分字体比新墩子高。
所以,加一步,用背景色覆盖小人及其上方部分。 - 墩子白色,或者浅绿色,与背景接近,使用OSTU二值化,效果不理想。 所以,加一步,
设定颜色范围为80~255,
如果有灰度值大于235(接近白色)的都直接变成80(底边界值)。 创建一个灰度化算法,与背景色在通道上差异较大者,远离背景灰度。通过buffer取10个像素RGB三个通道的平均背景色,然后每个元素与之做差求平方和。减少渐变影响,差在13以内,置为0。
然后,用此灰度图像,对背景进行漫水填充,闽值40,使用 BINARY_INV 方式,处理得到二值图。然后逐行搜索,找到顶点所在行。然后用数组方法,根据方差,对该行元素进行简易分类,得到最长连续像素范围,取中间值,即为顶点横坐标。
处理过程:
- 从frame中截取待处理区域
- 将小人用背景色覆盖
用背景色绘制矩形,覆盖小人。
黑色正方形为最终找到的顶点位置。 - 使用自定义的灰度方法,将图片增强灰度化
grayExt2.js#L8 - 高斯模糊+漫水填充背景。
高斯模糊能简易去除噪点儿影响
- 二值化
同样方式可以识别小药瓶:
识别小人的位置
使用opencv的templateMatch方法,可快速得到结果 findTarget2.js#L11:
...
let ballMat = cv.imread(path.resolve(__dirname, '..', 'ball.jpg'), 0) # 小人头部为固定图片
...
let { maxLoc: ballPoint } = colorMat
.bgrToGray()
.matchTemplate(ballMat, 3)
.minMaxLoc()
...
复制代码
结果中取maxLoc即可得到小人底座位置存入变量ballPoint
。每次取小球位置太准确了,以至于没有写异常捕捉。
其他技术点
- 使用 electron-vue 创建直接与socket交互的应用,并对外提供socket,用来获取当前图像。
- 使用 koa + vue,创建一个手动分析当前图像的web界面。opencv在此server中。
- 图片分析,取最大连续分类的算法: findTopXY.js#L24~L56 使用了数组方法,对当前行元素进行了简单的分类。
- electron-vue 每次调试会刷新,容易造成多次启动安卓二进制文件造成adb卡死,遂将部分逻辑放在外部server中。server间交互使用socket。这里使用
new Promise(r=>{cachedArray.push(r)}).then(...)
的方式,变种使用promise,完成socket返回数据之后继续执行代码逻辑。实现先蓄力,然后 n 毫秒之后返回处理结果,再判定弹跳时间。
TODO
- [ ] 整理server,使用此辅助完全非开箱即用。
含有buffer内容的数据传输,改为flatbuffer方式。- [x] 简易 demo 已完成: testFlatBuffer
不足与总结
这个辅助应用,是自己把所了解的技能连续堆积完成的,比demo大了。
此工具完全非开箱即用: electron 部分、opencv部分。
不足
- 中心位置跳偏,没有做修正。
- webpack 掌握欠缺,未配置 koa 热部署
- 使用图像处理取得顶点位置花费的时间,似乎比将每个墩子顶面截图使用templateMatch方法还要长。
- 缺少代码组织套路,代码可读性待提高。
总结
熟练了socket的使用、buffer的操作,熟悉了opencv的基本使用、vue directives的使用。尝试了使用python。
最后。
实时性效果,坊一个以前的没有opencv的自动极速变色龙的视频:
youtu.be/7YSpqiYZJ0w