TGideas-腾讯互动娱乐创意设计团队

使用单片机完成马里奥100排球

我都花钱买游戏了,为什么还要花时间玩它呢

马里奥奥德赛,一款让我找回了童年玩红白机快乐的游戏。主线通关后,便开始了寻月之旅。然后在经历了《超级马里奥:奥德赛》最难收集的5个月亮! 之后,我居然败给了沙滩排球,只差这最后一个了,强迫症不能忍。

图源:Super Mario Odyssey: Getting the Last Moon!! - By Insomnidex

看种种视频攻略,UP主们都说自己的方式超简单,但对我来说,都是一个感觉:

眼睛:嗯,我学会了
:不,你没有!

总是在五十几个的时候漏接(心里有个声音说,好浪费时间哦,我都花钱买游戏了,为什么还要花时间玩它)。于是开始了程序化控制NS的尝试实验。

把大象装……打住,好老的梗

程序化控制NS需要三个步骤:

  1. 实时获取游戏画面
  2. 分析画面
  3. 发送指令到NS

对应的硬件连接如图:

从左到右:

  • NS主机非Lite版
  • NS Dock Set(所以Lite版的用不了)
  • Arduino R3,用来模拟成一个手柄
  • 视频采集卡,用来传输游戏画面到电脑
  • USB转TTL模板/刷机线,电脑向Arduino发送指令用
  • PC,理论上三大系统都可以。作为整个流程的控制中心
  • 其它连接线缆,比如HDMI、方口USB线、杜邦线等

实时获取游戏画面

总体的运算逻辑在PC上面,所以首先要让PC“看到”当前的状态。

我使用了圆刚GC553视频采集卡,延迟比较低。将switch的HDMI端输入到采集卡,然后输出端为USB接口,在PC端可识别为一个摄像头,于是便可(准)实时地获取画面了。

分析画面

常见的视频分析都是opencv(python)写的。如果能用上自己熟悉的语言,会更顺手。刚好有opencv4nodejs opencv的非官方nodejs版,适合前端的我,于是便用上。

object tracking

opencv4nodejs项目的examples提供了两种 object tracking 方式,一种是运动(通过比较不同帧画面差异),一种是根据颜色。(它的例子不是传统的tracking,两次识别没有关联,但适合这个实验)

图源:opencv4nodejs examples

识别出帽子和排球

这里最适合的是用颜色来作帽子和排球的区分,因为画面运动的物品不止球和帽子,还有边上跳动的小人、甚至变动的UI元素,并且只凭运动也很难区分帽子和排球。

而马里奥的帽子可以更换,换成和排球颜色差异较大的颜色。

  • 排球:红白相间,主要是红色
  • 帽子:选择路易吉的绿色帽
// 根据颜色识别帽子和排球的部分代码
// grabFrames(摄像头, 当前帧)
grabFrames(0, (frame) => {
  const frameRGB = frame.cvtColor(cv.COLOR_BGR2RGB);
  // hat
  const hatColorUpper = new cv.Vec(111, 192, 52);
  const hatColorLower = new cv.Vec(58, 105, 29);
  const hatRangeMask = frameRGB.inRange(hatColorLower, hatColorUpper);

  const hatBlurred = hatRangeMask.blur(new cv.Size(10, 10));
  const hatThresholded = hatBlurred.threshold(100, 255, cv.THRESH_BINARY);

  const hatMinPxSize = 20;
  const hatFixedRectWidth = 20;
  let positionHat=drawRectAroundBlobs(thresholded, frame, hatMinPxSize, hatFixedRectWidth,[0,255,0]);

  // ball
  const hatColorUpper = new cv.Vec(254, 110, 110);

  // ... 同上

  cv.imshow('hatRangeMask', hatRangeMask);
  cv.imshow('ballRangeMask', ballRangeMask);
  cv.imshow('hatThresholded', hatThresholded);
  cv.imshow('ballThresholded', ballThresholded);
  cv.imshow('frame', frame);

  // ...

}

 

接球的逻辑

连线帽子和排球,方向指向排球,让帽子沿着这个箭头运动即可

发送指令到switch

对于未越狱的switch来说,控制会比较难,直到这个项目的出现:Switch-Fightstick

Switch-Fightstick

这个项目不会直接用到,但是我可以(zha)简(fan)介(yi)一下:

在2017年的NS 3.0.0系统发布之后,可以支持兼容的三方手柄了,比如 HORI 生产的 The Pokken Tournament Pro Pad,虽然它不支持摇杆,但由于 HORI 同时也为别的游戏主机生产手柄,所以描述符(Descriptors)应该会非常类似,于是这个项目利用了LUFA和对 HORI 生产的 The Pokken Tournament Pro Pad 的反编译工作,产出了一个和Switch Pro 手柄功能几乎一样的模拟手柄。

大量的基于 Switch-Fightstick 的项目就出来了,比如下面这个,本次实验直接用到的:KawaSwitch/Poke-Controller

Poke-Controller

原本是专为pokerman孵蛋自动化等操作做的项目,UI做得非常完善(然而我并不需要那么复杂,其实)。这个项目代码分为两部分,一部分代码用于编译后烧入单片机的,另一部分是python写的界面、控制脚本等,用来向单片机发送指令。

大概看了下,原理是向电脑的串口发送指令就好了;并且在这个排球的实验中,我只需要方向控制即可。于是觉得可以用Nodejs写一下这部分的逻辑。这样我就可以继续用熟悉的语言,和前面的识别部分整合起来了。

单片机的烧录

如果是Arduino Uno R3(16u2)可以直接下载编译好的固件,后缀是hex。然后让单片机和PC通过USB连线,其余的线不连。再参考这里刷入。

接线

烧录完之后,连线方式会改成下图所示。让 Arduino 和 NS 底座直接相连,然后 PC 连接USB转TTL模板,再通过杜邦线连接Arduino。这里只需要 TxD 和 RxD 两条线,不用连接电源

发送指令

nodejs使用serialport即可发送命令到COM端口,每条命令用\r\n结尾。

由于马里奥排球这儿,只需要用到左摇杆,所以只用关注它。左摇杆的指令是 2 8 x y \r\n ,x 和 y 都是16进制,取值范围是0-ff。例如对x来说,0就是左摇杆往左推到底,ff就是左摇杆往右推到底,80(对应的十进制是128)则是中间位置,表示在x方向没有运动。y同理,往上推到底是ff。

下面是让左摇杆推到某个角度的代码,同时加上了推杆的力度参数:

const SerialPort = require('serialport')
const Readline = require('@serialport/parser-readline')
const port = new SerialPort('COM3', { baudRate: 9600 })
const parser = new Readline()
port.pipe(parser)

function release(key='\r\n'){
  port.write(`2 8 80 80\r\n`)
}

function goAngle(angle,strength=1){
  // angle:摇杆的方向,度数
  // strength:摇杆不动为0,推到底为1
  if(angle===null){
    release();
  };
  const MAX=255, MIN=0, HALF=(MAX-MIN)/2, RADIUS=strength*(MAX-MIN)/2;
  let degree=angle/180*Math.PI;
  let x=Math.cos(degree)*RADIUS+HALF;
  let y=HALF-Math.sin(degree)*RADIUS;
  let xy=[x,y].map(v=>Math.round(v).toString(16))
  port.write(`2 8 ${xy[0]} ${xy[1]}\r\n`);
  return [x,y]
}

最终,经过几次调试,破百!

遇到的问题

整个过程遇到的问题挺杂的,小问题、报错什么的,Google 可以解决。剩余的如下:

编译固件

在Windows下编译的小问题挺多的,我遇到一个就搜一下然后解决一个,这样五六趟之后,终于卡在某一步骤了。后来干脆使用Windows的Linux子系统来编译,出乎意料地好用,几乎一趟搞定。推荐Windows用户安装WSL,然后编译指引直接用Linux的可以了。

如果Arduino是ATmega16U2芯片,可以免去这一步,直接下载Poke-Controller项目中编译好的。

延迟

延迟来源于采集卡延迟+图像分析耗时的累加

最终导致的效果就是,帽子飞向的其实是前(大约)0.3秒的位置,可能会漏接。

我觉得最好的办法是推算球的运行轨迹,然后提前一点去到球的前方,但我的目标只是过百,应该不用那么精确。所以加了一个粗略的小策略:球、场地中心、帽子三者的夹角,原本是期望的帽子的运行方向,现在适当地减小一些。夹角越大,减小的量越小。

球会“消失”

因为排球不是纯红色的,游戏过程中,随着球的翻滚,可能会出现红色的面积过小,然后就低于前面设置的阈值,于是球就“消失”了。

又或者因为遮挡等原因,球和帽子间的遮挡、UI元素和球的遮挡等。

如果单纯靠调低阈值,又会提升识别噪音,造成干扰。想了一下,球是一直运动着的,即便是“消失”也是零点几秒,于是就添加了一个逻辑,如果识别不到球的位置,就假设它是在最后一次被看见的地方。

结语

999月亮成就达成!拔卡,二手,回血,素质三连

参考资料

How I Cheesed the VOLLEYBALL Challenge in Super Mario Odyssey

A deep dive into the Volleybot code code for the previous video. ( I discovered it weeks later, didn't watch it although ╮(╯▽╰)╭ )

KawaSwitch/Poke-Controller Automation support software for the Nintendo Switch using LUFA Project (AVR), serial communication and video capture

Switch-Fightstick Proof-of-Concept LUFA Project for the Nintendo Switch. Uses reverse-engineering of the Pokken Tournament Pro Pad for the Wii U and Switch System v3.0.0

Manually download Windows Subsystem for Linux distro packages Just for LTSC users like me. For the easiest method is to purchase via the Microsoft Store.

Object Tracking using OpenCV ( Not directly used in this demo but inspired me a lot. )

求经验
关闭

分享

返回顶部