キャプチャボード(OpenCV/画像認識) + Raspberry Pi4(プロコン偽装) による Nintendo Switch版 流星のロックマン3を自動操作するバトル周回BOTを作りました。
- ソースコード配布
- どのようにNintendo SwitchゲームのBotを作るのか?
- Raspberry Pi4でNintendo Switch Proコントローラーをマクロ化させる
- 画像認識&プロコン自動操作でエンカウントから戦闘まで自動化
はじめに
流星のロックマン パーフェクトコレクションでランクマッチが導入され、20年越しに対戦やりこむぞ!!と意気込むものの…特に流星3のガチの対戦構築を揃えるにはとてつもない時間がかかります。
イリーガルカード収集/ノイズチェンジ毎の100-200回の対戦アビリティ/金ミステリー/ブラザーと120戦の絆力稼ぎ…
これらを最短でこなしランクマで上を目指すには、switch本体&ソフトの複数台所有、セーブデータバックアップによるバトルカード複製、連射コン、マクロコンを使わなければなりません。ロックマンエグゼのコレクション版からの改善も無く…
これがカプコンの求めた「対戦で強くなるための努力の方向性」です。
ということで今回アシスト機能を活かしつつV3ボス討伐をイリーガルカード収集するバトル周回BOTを作りました。
すぐ動かせるソース全文公開しつつ、キモとなるRaspberry Pi4とOpenCVの画像認識アルゴリズムの部分を解説をしていきます。
どのようにNintendo SwitchゲームのBotを作るのか?
procon_bypass_manをご存知でしょうか。
スプラトゥーンのボトルガイザーやパブロを連射できる、正規のプロコンの連射コン化するアプリです。
プロコンとswitch本体の間にRaspberry Piに繋ぎ、通信をバイパスさせる仕組みです。
連射化はもちろん、ラズパイ側からプログラムによってボタン押下の信号を送ることが可能です。
今回は更にキャプチャボードでゲーム画面を認識させることで、単なるマクロにとどまらないエンカウントからV3ボス撃破を周回させるBOTを実装しました。
ハードウェア構成です。
プロコン(本物)
│ USB typeC
▼
Raspberry Pi4 model B ── USB typeA ───────────→ Nintendo Switch
▲ │ HDMI
│ Wi-Fi / WebSocket (port 9200) ▼
│ キャプチャボード Elgato HD60S
│ │ USB typeC
│ ▼
└───────────────────────────────────────────── PC(画像認識)
Raspberry PiがPro Controller に偽装して Switch に接続し、PC がゲーム画面を見てボタン操作を指示します。この記事ではその仕組みを順に解説します。
Raspberry Pi4でNintendo Switch Proコントローラーをマクロ化させる
Raspberry Pi4のUSB Gadget Driverを使い、自身をUSBデバイスとしてswitchに認識させることができます。
セットアップ詳しい解説はprocon_bypass_manの記事が参考になるので省きますが、
簡単に説明すると、PCと同じローカルネットワークに配置してSSH接続を許可し、ラズパイのモニタ映し出しをすること無く、PCの側から全て操作を完結させます。
procon_bypass_manではruby使ってて微妙なのでpython3を使える環境をセットアップします。
PC側で実装した.pyはSSH経由でラズパイ上に配置し、バイパスアプリを実行します。
Raspberry Pi にプロコンを繋いでバイパスさせる
Raspberry Pi の USB OTG機能を使うと、Raspberry Pi自身をUSBデバイスとして振る舞わせることができる。libcomposite カーネルモジュールで HID デバイスを定義すると /dev/hidg0 が生成され、書き込んだデータが Switch に届く。
BASE = '/sys/kernel/config/usb_gadget/procon'
os.makedirs(BASE, exist_ok=True)
# Pro Controller の Vendor/Product ID をそのまま使う
swrite(f'{BASE}/idVendor', '0x057e') # Nintendo
swrite(f'{BASE}/idProduct', '0x2009') # Pro Controller
swrite(f'{BASE}/functions/hid.usb0/report_length', '64') # 64バイトレポート
Switch は USB の Vendor/Product ID を見てデバイスを識別するので、Nintendo の ID をそのまま使えば本物と区別できません。
プロコンの通信仕様:ボタン状態のスナップショットを送り続ける
プロコンは「今この瞬間に何が押されているか」という状態を8ms(120Hz)ごとにSwitchへ送り続ける。web系に良くあるkeydown/keyupのようなイベントではない。
ボタンを押している間はビットを立てたまま送り続け、離した瞬間にビットを落としたパケットを送れば「離し」になる。
A ボタンを1秒押す = 「A が押されている」レポートを 125 回送信することと同義。
Webのキーイベント: [押した瞬間 keydown 1回] ─────── [離した瞬間 keyup 1回]
HIDレポート: [状態][状態][状態][状態]...(8ms ごと)...[状態][状態]
A=1 A=1 A=1 A=1 A=0 A=0
HID レポートのバイト構造
プロコンの Standard Full モード(Report ID 0x30)は 64 バイト。
byte[0] : Report ID = 0x30
byte[1] : タイマー(毎フレームでインクリメント)
byte[2] : バッテリー残量
byte[3] : 右ボタン ZR R SR SL A B X Y
byte[4] : 共通ボタン - + ThumbR ThumbL Home Cap
byte[5] : 左ボタン ZL L SL SR ← → ↑ ↓
byte[6-11]: 左右スティック(12bit×2 をパック)
byte[12] : バイブレーション
byte[13-48]: 6軸 IMU(加速度+ジャイロ × 3サンプル)
Switch ProコントローラーをWebSocket/ブラウザで操作できるようにしてみる
HIDの通信仕様通りのパケットの仕様で、websocketサーバーを搭載しました。
PC → Raspberry Pi 間は WebSocket (port 9200) で JSON を投げます。
{"buttons": ["a", "b"]}
「今この瞬間 A と B が押されている」という状態を表す。8msごとに送り続けることでボタン押しっぱなしを表現します。HID の仕様そのままです。
PC(画像認識: CPU を使い放題)
│ WebSocket(8ms周期でボタン状態を送る)
▼
Raspberry Pi(HID転送: 他の処理をしない。ひたすら転送するだけ)
│ USB HID
▼
Nintendo Switch
実際に hid_controller.html というブラウザから操作するUIも作りました。
WebSocketで分離することで、重い処理はPCに、HID転送はラズパイに任せる構成が自然に作れました。特定の技術スタックに依存しない取り回しのよさが決め手でした。
<動画>
画像認識&プロコン自動操作でエンカウントから戦闘まで自動化させる
さて、プログラムによってプロコンの操作ができるようになりました。
アシスト機能による、「バスター500%」「エンカウント率最高」状態にし、コダマタウンでステルスボディ/サーチアイを駆使することでほぼ毎回スペードマグネッツV3とエンカウントします。
また、イリーガルカード収集なのでノイズ率300%以上稼ぐ必要があり、無属性非暗転をAボタンを押すだけで当てたいため、「オートロックオン」を利用しました。この条件を整えたうえで、
フィールド上を左右移動
↓
エンカウント
↓
カスタム選択
↓
Aボタンでカード使用
↓
左右に移動してBボタンでバスター連射で削る
↓
ゲージ溜まったら再度カスタムに入る
↓
以下ループ
という操作をするプログラムを書いていきます。
今回はV3ボスを対象にしているのであまり関係ないですが、
一応、ウィルス相手にイリーガル集めをする場合、REGカードに「インパクトキャノン」や「ジャイアントアックス」を指定したいところです。
REG指定したカードは必ず1T目の左上に配置されるものの、流星3特有のカード重なりシステムが悪さをし、REGカードが下に行くことが多々あります。(が、対人戦では必ず上に来る仕様です。これを標準にしてくれ)
現在のカスタムが1T目かどうかを判定し、REGが重なってるケースではシングルユーズをさせる仕様として設計しています。
キャプチャボードのゲーム画面を OpenCV で取得する
キャプチャボードElgato HD60Sを使いSwitchのHDMI出力をcv2.VideoCapture でカメラとして読み込みます。HD60SはSwitch2の映像読めないっぽいので初代Switch使い、1920 x 1080の動画を取得します。
また、画像認識のためにパフェコレ側の設定は、画面分割を上下の1:1で固定にします。
各オブジェクトの表示位置pxが固定すると実装のラクさが段違いなので。
cap = cv2.VideoCapture(0, cv2.CAP_MSMF)
cap.set(cv2.CAP_PROP_FPS, 60)
while cap.isOpened():
ret, frame = cap.read()
state = detector.detect(frame)
if state != current_state:
sequencer.on_state_change(current_state, state)
current_state = state
60fps でフレームを取り続け、毎フレーム状態検出にかけます。
ゲーム解析&ボタン操作のアルゴリズムを考える
ゲーム画面は連続したフレームの流れですが、「フィールドにいる/カスタム画面/バトル中/リザルト」のような離散的な"場面"を持ちます。これをステート(状態)と呼ぶことにします。
フレームごとにステートを判定し、ステート毎に決められたボタン操作をさせることで、バトルの周回を行います。
以下、ステートとその時どのボタンを押させるかの表です。この1連の流れを繰り返しバトル周回を実現します。
| ステート | ボタン | 判定アルゴリズム | 備考 |
|---|---|---|---|
| フィールドマップ | 300ms毎に左右を往復。その後3秒ごとAを3回連打 | 下画面に「ZENNY」表示があることで判定。zenny.bmp をテンプレートマッチ。 |
エンカウント誘発のため左右移動し続け、ノイズチェンジ発生時のテキストスキップに A を挟みます |
| 1ターン目カスタム | (1) メテオサーバー検出時は +→↓→↓→A→B でキャンセル (2) +→↓→←→← でカーソル左上へ (3) 6枚を時計回りに走査、攻撃力 (0表示) があるカードのみ A×2 で選択 (4) 残無し検出 or 全周終了で +→A バトル開始 |
(1) 下画面の右上でOKボタンのテンプレがマッチ(2) 下画面のカーソル表示を直近10F内に検出 (3) 直近5F中3F以上が黒フレーム (暗転検出) | バトル開始直後の最初のカード選択フェーズ |
| 2ターン目以降カスタム | 同上(暗転なしで2回目以降と判別) | OK + カーソル検出(CUSTOM_1ST と同じ)+ 暗転フレームなし | バトル途中のターン交代カスタム |
| シングルユーズ選択 | 500ms待機 → → → A → + → A
|
下画面の中央にシングルユーズボタンのテンプレがマッチ | カード選択後にシングルユーズが出た場合のサブ画面 |
| バトル中 |
A 連打 2秒 → 以降 B 押しっぱなし + 0.5秒ごとに A+B 150ms 同時押し |
座標固定であるカスタムゲージ範囲に水色ピクセルが1px 以上あれば(ゲージが溜まっている最中) | ロックマン攻撃ルーティン。Aでカード使用、Bでバスター |
| バトル中(ゲージマックス) | カスタム入るためR連打 |
上記と同ROIをHSV化し緑 (H:55–70, S:130–255, V:100–255) ピクセルの外接矩形がROI全縦断 | ゲージMAX時は緑色でギラギラアニメで光る。inRangeのHSV判定で吸収できた |
| リザルト |
A 連打 |
上画面の上部ROIから、フォント部分の白ピクセルを抽出後、result.bmp テンプレマッチ |
バトル終了画面のテキスト送り |
| それ以外 | 直前がFIELDならエンカウント演出スキップのためA連打、それ以外は無視 |
上記いずれにも当てはまらない | 演出中・メニュー中など分類不能フレーム |
また、カスタム1T目とカスタム2T目は画面の見た目が完全に同じで、画像認識単体では不可能で、時系列バッファを持つことで区別させます。
「バトル開始時には必ず一瞬画面が真っ暗になる」ので、直近10フレームを保持しておき、直近 5 フレーム中 3 フレーム以上がほぼ真っ黒 ならバトル開始直後のカスタム = 1ターン目、そうでなければターン交代カスタム = 2ターン目以降と判別します。
古典的画像アルゴリズムでゲーム画面の解析
では、具体的にステート判定のロジックを実装していきます。OpenCVの以下の2つの手法で実現できます。
cv2.matchTemplate()によるテンプレートマッチング
小さい画像(テンプレート)を大きい画像の上でスライドさせながら、各位置でどれだけ似ているかをスコア化します。TM_CCOEFF_NORMED を使うと0.0〜1.0に正規化されたスコアが得られ、1.0が完全一致です。
例えば、ゲーム画面上に「ZENNY」という文字が写っているか?を調べるために使います。
下画面のゼニー表示をテンプレートマッチで検出します。フィールド中のみ常時表示されます。シンプルなテンプレートマッチングで判定できます。
画面分割を上下の1:1で固定しているので、「ZENNY」表示の座標を切り取ったうえでテンプレートマッチングさせてます。
def _is_field(self, frame):
roi = frame[1000:1080, 600:1320] # 右下端のROI
res = cv2.matchTemplate(roi, self.tmpl_zenny, cv2.TM_CCOEFF_NORMED)
_, score, _, _ = cv2.minMaxLoc(res)
return score >= 0.80
cv2.inRange()について
「指定した色の範囲に入っているピクセルだけを抽出する」フィルタです。
各ピクセルのBGR値(または後述のHSV値)が指定した下限〜上限の範囲内なら白(255)、範囲外なら黒(0)にしたマスク画像を返します。
「この色が画面上に存在するか?」を高速に判定するのに使います。テンプレートマッチと違い、位置ではなく色だけで判断するので処理が軽いです。
今回のアプリでは、カスタムゲージの状態の判定や、リザルトの白色のフォント部分の検出に活用してます。
frameをスライス記法[start:stop]を2次元に拡張すると矩形領域を切り出し使います。
この「切り取り」のことを OpenCV では ROI(Region of Interest / 関心領域)と呼びます。
inRange()はROIに対して使うのが基本です。
def _is_battle(self, frame):
roi = frame[86:286, 1280:1291] # 右端の細い縦帯
mask = cv2.inRange(roi, (235, 220, 85), (255, 240, 105)) # 水色
return bool(np.any(mask > 0))
上記の2つの手法を駆使して、ゲームの状態を判定していきます。
フィールド判定
下画面のゼニー表示をテンプレートマッチで検出します。フィールド中のみ常時表示されます。シンプルなテンプレートマッチングで判定できます。
画面分割を上下の1:1で固定しているので、「ZENNY」表示の座標を切り取ったうえでテンプレートマッチングさせてます。
def _is_field(self, frame):
roi = frame[1000:1080, 600:1320] # 右下端のROI
res = cv2.matchTemplate(roi, self.tmpl_zenny, cv2.TM_CCOEFF_NORMED)
_, score, _, _ = cv2.minMaxLoc(res)
return score >= 0.80
バトル中判定
上画面右にあるカスタムゲージのピクセル領域に水色のゲージバーをcv2.inRangeピクセルの色で直接検出します。
ゲージのピクセル領域に水色が1pxでもあったらカスタムゲージが伸びている最中という判定ロジックです。
テンプレートマッチより軽いです。
def _is_battle(self, frame):
roi = frame[86:286, 1280:1291] # 右端の細い縦帯
mask = cv2.inRange(roi, (235, 220, 85), (255, 240, 105)) # 水色
return bool(np.any(mask > 0))
ゲージMAX判定(カスタムに入れる状態)
カスタムゲージが溜まると、ゲージの色が水色→戻り色でギラギラ光ります。
同じカスタムゲージ領域のROIをcv2.inRange()でHSVでマスク化し、緑ピクセルの外接矩形がROI全体を縦断していれば ゲージMAX とみなします。
def _is_gauge_max(self, frame):
roi = frame[86:286, 1280:1291]
hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv, (55, 130, 100), (70, 255, 255)) # 緑
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
_, y, _, h = cv2.boundingRect(np.vstack(contours))
return y == 0 and y + h >= roi.shape[0] # 上端〜下端まで緑
リザルト画面判定
シンプルなテンプレートマッチングでも良いが、「RESULT」のフォントの白色ピクセルだけを残すマスク処理を行ったうえでテンプレートマッチングを行っています。
「リザルト画面は背景アニメや演出で色が変わりやすいが、フォントの白は安定している」という前提のロジックです。
def _is_result(self, frame):
roi = frame[0:540, 600:1320]
white_mask = cv2.inRange(roi, (224, 235, 227), (255, 255, 255))
processed = np.zeros_like(roi)
processed[white_mask > 0] = (255, 255, 255)
res = cv2.matchTemplate(processed, self.tmpl_result, cv2.TM_CCOEFF_NORMED)
_, score, _, _ = cv2.minMaxLoc(res)
return score >= 0.75
カスタム画面の検出
「今の画面がカスタム画面である」ことを2つのテンプレートマッチで確認す
テンプレートマッチングで下画面にOKボタンの表示&カーソルの表示
がされているときカスタム画面と判定します。
ただし、直近5フレーム中3フレーム以上が暗転フレームだったら
バトル開始直後と判断=1T目と判定しています。
REGカードを一番最初に選びたいため、1T目は左上カードの選び、
裏にあった場合はシングルユーズさせるようにしています。
どのカードの選択するか
※かなり適当なロジックで実装してます。改善の余地大いにありです。
流星3はカスタム画面でカードが上下に重なるというシステムがあります。
本来、表側にあるカードの中から最大の火力を取れるものを選択してバトルに入るのが最適といえるでしょう。
そしてパフェコレでは、十字キーで裏側にあるカード含め6枚全てにカレントカーソルを移動できるようになっています。
表側にあるカードの判定は、攻撃力表示があるかどうかで判定することにしました。
下1桁は大体0なのでそこをテンプレートマッチすることにしました。
カレントカーソルのROI領域を切り出し、そこに攻撃力表示があれば、Aボタンを押して選択し、十字キーで別のカードに移動して、その判定と選択を繰り返す…という具合です。
カスタム画面は 3×2 のグリッド。時計回りに走査する。
[1] → [2] → [3]
↓
[6] ← [5] ← [4]
各カードの「攻撃力があるか」を OpenCV で判定して A を押します。攻撃力表示の数字テンプレート(0_white.bmp / 0_red.bmp)をカード右端 90px に対してマッチし、スコアがしきい値以上なら攻撃力ありと判断します。
ソースコード全文
使い方とかREADMEに色々書いてあります。
この記事内ではあくまでラズパイによるプロコン偽装と、OpenCVの画像認識手法の解説にとどめています。
カスタムにファイナライズボタンがあるとカードの攻撃力表示判定が出来ないため、ファイナライズボタンをキャンセルさせる処理とかもしています。
Claude Codeに読ませて色々聞いてみるとわかりやすいです。
感想とカプコンへ
Raspberry Pi4によって、プロコンの通信偽装ができるハックテクと、
OpenCVを使った画像認識はこういうアルゴリズムで実装できるよーってこの記事でお伝えできたらなと思います。
なおOpenCVでは以前、スプラトゥーンのキルデス報告をAIひろゆきに読み上げさせるアプリを作りました。
最後にカプコンへ。DS版の全7本をまとめた「流星のロックマン パーフェクトコレクション」、Switchで遊べるようになったことは大変ありがたいです。
が、対戦周りはあまりにも理解が足りてません。
リバイバル作品でありながら対戦のための収集要素のしんどさが原作通りであることもさながら、レンタルデッキの内容も…対戦を理解した人間の手が加わっているものではありませんでした。
20年も経ちますから当時の対戦環境を知るカプコンのスタッフがいないとは考えづらいですが、対戦環境を知る社員にそのあたりの裁量を与えることも無く、意見を伺う事もなかったのでしょうか?
ところで22年ぶりに新作が出たカービィのエアライダーという作品があります。
版権元であるHAL研究所を退職されている桜井さんが引き続きディレクターを務め、
エアライダーダイレクトでは桜井さんに自らが発売前にゲームの内容について語る番組が大きな反響があったことで有名です。
22年間エアライドを擦り続けてきた廃人勢を満足させる内容に仕上げられたことが大変羨ましく思いますね!
退職後も関係が良好なHAL研と桜井ディレクター。
退職で縁が切れたカプコンとロックマンの父、稲船ディレクター。
ゲームのクオリティにカプコンの社風というものが、ほんのり滲み出てるのかも知れませんね。
とりあえず対戦勢からするとオンラインが過疎ると困るので、全開放機能つけるか、バトルポイントで対戦主要カードのセットやアビリティーを買えるようにするのがよかったんじゃないかなと思います。レンタルデッキで潜り、本編のカードプールも埋めつつ対戦を擦り続ける楽しみ方もありだと思うのです。
今からでもアプデで是非改善して下さい!!!




