PyAutoGUI + OpenCVでゲームを自動操作する|ドラクエⅢのレベル上げを全自動化した実装解説

目次
  1. 🎮 作ったもの:ドラクエⅢ(Steam版)のレベル上げを全自動化するスクリプト
  2. 🔍 自動化の仕組み:画面を見て状態を判定し、キーを押すだけ
  3. 💻 実装コード全文と、関数ごとの設計意図
  4. 🛠️ テンプレート画像の作り方とパラメータの決め方
  5. 🧯 つまずきどころと対処法
  6. 🚀 ここから先の改善アイデア
  7. 🏁 まとめ:画面認識+入力自動化は「状態設計」が9割
本記事にはプロモーション(広告)が含まれています

PythonでGUI自動化というと「業務のRPAもどき」くらいしか使い道が思いつかない、という人は多いと思います。私もそうでした。が、OpenCVのテンプレートマッチングと組み合わせた瞬間に、これは「画面を見て判断して操作する」汎用エージェントの最小構成だと気づきます。

題材はドラゴンクエストⅢ(HD-2Dリメイク版・Steam)のレベル上げを全自動化したスクリプトです。mssで画面をキャプチャし、OpenCVで状態を判定して、PyAutoGUIでキーを送る。この流れを実装レベルで追います。コードは実際に動かしたものをそのまま載せ、閾値やキャプチャ範囲といったパラメータの決め方、うまく動かないときの切り分け方まで書きました。

🎮 作ったもの:ドラクエⅢ(Steam版)のレベル上げを全自動化するスクリプト

きっかけは「はぐれメタル狩り」という単純作業

RPGにつきもののレベル上げ、正直に言って面倒ではないでしょうか。たくさん経験値をくれるモンスターと、その出現場所はだいたい決まっていて、あとはひたすら狩るだけ。「そこが醍醐味でしょ」という意見は今回は受け付けません。私はボス戦の演出やストーリーに重きを置いている派で、心を空にして何時間も同じ操作を繰り返せるほどメンタルが強くないのです。

こういう単純作業を見ると自動化したくなるのがエンジニアの性……というより、単にめんどくさがりなだけですね。やっていることは「画面を見る」「状況を判断する」「ボタンを押す」の3つだけなので、素直にプログラムに落とせます😅

前提知識:はぐメタ・会心必中・ドラゴラム・スライム島・ランダムエンカウント

ドラクエを触ったことがない方のために、設計の前提になる用語だけ押さえておきます。

用語 意味 自動化への影響
はぐれメタル(はぐメタ) 今作最大の経験値を持つモンスター。防御力が異常に高く通常攻撃は1ダメージ、魔法もほぼ無効。HPは5しかないが素早さが高く高確率で逃げる 「出たら最速で殴る」処理が要る
会心必中 会心は相手の防御力を無視して攻撃できる。通常の発生率は数%だが、特定の職業・特技なら100%発生させられる はぐメタを確実に削る手段
ドラゴラム 自分が竜に変身してブレスを吐く呪文。ブレスなので、はぐメタの防御力を無視できる 同上
スライム島 スライム系統しか出現しない島。はぐメタもスライム系統なので、経験値効率が高いとされる 出現モンスターが限定でき、判定パターンが減る
ランダムエンカウント フィールドを歩くと一定確率で戦闘が発生する方式 「歩き続ければいつか出る」=キー連打で成立する

要するに「スライム島を歩き回り、はぐメタが出たら会心必中やドラゴラムで倒し、それ以外は逃げる」を延々と繰り返すだけの作業です。ここまで単純化できれば、あとは画面から状態を判定するだけで自動化が成立します。

はぐれメタル、スライム島、エンカウント時の画面 画像出典:ドラゴンクエストIII そして伝説へ…(HD-2D版) © SQUARE ENIX

放置6時間でLv.50→Lv.99になった話

このシステムを作ったばかりのころ、主人公はLv.50くらいでした。そのまま6時間ほど放置していたら、カンストのLv.99になっていました。寝ている間に勝手にカンストしているのは、なかなか気分のいいものです。

ドラクエⅢは転職するとレベルが1に戻る仕様なので、転職先をうんうん唸って選ぶ必要もなくなりました。適当に転職しても放置で戻せます(賢者だけは別の事情があるので、そこは自分で考えてください)。

もちろん実行中、マウスやキーボードには指一本触れていません🙌

フィールド上で自動的に左右へ移動してエンカウントを待つ様子 画像出典:ドラゴンクエストIII そして伝説へ…(HD-2D版) © SQUARE ENIX

はぐれメタルとの戦闘を自動で進める様子 画像出典:ドラゴンクエストIII そして伝説へ…(HD-2D版) © SQUARE ENIX

はぐれメタル以外の戦闘で自動的に「にげる」を選ぶ様子 画像出典:ドラゴンクエストIII そして伝説へ…(HD-2D版) © SQUARE ENIX

自動化する前に確認したいこと(利用規約・オンライン要素・自己責任)

技術的には面白い題材ですが、やる前に確認してほしいことがあります。

  • 対象がオフラインのシングルプレイであること。オンライン要素やマルチプレイ、ランキング、対人要素があるタイトルで入力自動化をすると、他のプレイヤーへの影響が出ますし、規約違反やアカウント停止の対象になり得ます。
  • そのタイトルの利用規約・EULAを読むこと。入力の自動化やマクロの扱いはタイトルごとに違うので、公式の規約を自分で確認してください。
  • ⚠️ アンチチートやオーバーレイとの相性。外部からの入力送信を検知する仕組みが入っている場合があります。今回の構成はメモリ改ざんやプロセスへの介入は一切していませんが、「検知されない保証」はありません。
  • セーブデータのバックアップ。想定外のキーが暴発して、意図しないメニュー操作やセーブが走る可能性はゼロではありません。

最終的には自己責任です。この記事は、画面認識と入力自動化という技術の解説として読んでください。

🔍 自動化の仕組み:画面を見て状態を判定し、キーを押すだけ

処理フロー:キャプチャ → テンプレートマッチング → 状態分類 → キー操作

処理は驚くほど単純で、次のループを回し続けるだけです。

[1] mss で画面(左上1/4)をキャプチャ
      ↓ numpy 配列
[2] OpenCV の matchTemplate で3枚のテンプレートと照合
      ↓ 最大スコアが閾値以上のものを採用
[3] 状態を分類(lost_metal / in_battle / on_field / None)
      ↓
[4] 状態ごとに決めたキー操作を PyAutoGUI で送信
      ↓
[1] に戻る(interval 秒に1回のペース)

画像認識というと機械学習を思い浮かべるかもしれませんが、UIのように「同じ画像が同じ位置に出る」対象なら、テンプレートマッチングで十分です。学習データも不要で、実装は数十行で済みます✨

判定する3状態(はぐメタ出現/戦闘中/フィールド上)の切り分け方

判定するのは次の3状態だけです。

状態名 テンプレートに使う画像 やらせたい操作
lost_metal 戦闘画面に表示されるはぐれメタルの名前・グラフィックの一部 フィールドに戻るまでひたすら決定キー(=会心必中やドラゴラムの攻撃を繰り返す)
in_battle 戦闘コマンドウィンドウの一部 「にげる」を選んで戦闘を抜ける
on_field フィールド画面にだけ出るUI(ミニマップなど、常に表示される領域) 左右に歩いてエンカウントを起こす

ポイントは状態を3つに絞ったことです。実際のゲーム画面には、メッセージ送り中、勝利演出中、レベルアップ表示中など無数の中間状態がありますが、それを全部拾おうとすると判定が破綻します。「どれにも当てはまらない=None=何もしない」でループを1周飛ばす設計にしておけば、次のループで正しい状態に落ち着きます。

判定順にも意味があります。detect_state は辞書を先頭から順に見て、最初にヒットした状態を返して終了します。はぐメタ出現時は「はぐメタのグラフィック」と「戦闘コマンドウィンドウ」が同時に画面に出るので、lost_metal を先に評価しなければいけません。Python 3.7以降の辞書は挿入順を保持するので、template_paths に書いた順序がそのまま優先順位になります。ここは暗黙の依存なので、コメントを残しておくと後で自分が助かります。

状態判定に使う3つのテンプレート画像(はぐれメタル、スライム島の岩、バトルコマンド) 画像出典:ドラゴンクエストIII そして伝説へ…(HD-2D版) © SQUARE ENIX

はぐメタがいない戦闘は「にげる」に倒した理由

単純に時間の無駄だからです。スライム島でもはぐメタ以外は出ますし、中にはこちらを眠らせてきたり、味方モンスターを回復してくる相手もいます。まともに戦うと1戦闘に数十秒持っていかれることがあり、経験値効率の敵です。

💡 「逃げるコマンドが失敗したらどうするの」というツッコミもあると思います。ドラクエには一定のレベル差があれば逃走が失敗しない仕様があり、スライム島に出てくる面々が相手なら、その心配はほぼありません。仮に失敗しても、次のループで再び in_battle と判定されてもう一度「にげる」を選ぶので、実害はありません。失敗してもループが自己回復する設計にしておくのが、この手の自動化のコツです。

OpenCV・PyAutoGUI・mssの役割分担

3つのライブラリをきれいに役割分担させています。mssは画面キャプチャ専用で、領域を辞書(top / left / width / height)で指定でき、PyAutoGUIのscreenshot()より高速なのが採用理由です。返ってくるのはBGRAのバッファなので、np.array()してからcv2.cvtColorでBGRに変換します。OpenCVはテンプレートマッチング(cv2.matchTemplate)と最大スコアの取得(cv2.minMaxLoc)、それに画像の読み込み(cv2.imread)を担当。PyAutoGUIはキー入力の送信(keyDown / keyUp)だけで、今回はマウスを一切使いません。

インストールは次の通りです。

pip install opencv-python numpy mss pyautogui

💻 実装コード全文と、関数ごとの設計意図

まずは全文です。実際に動かしていたものから、パスだけ環境非依存な書き方に直してあります。

import cv2
import numpy as np
import mss
import time
import pyautogui
import random

def get_monitor_area_for_top_left_quarter():
    """
    画面左上の1/4領域を計算して返す。
    """
    with mss.mss() as sct:
        monitor = sct.monitors[0]  # 全画面モニタ情報を取得
        return {
            "top": monitor["top"],
            "left": monitor["left"],
            "width": monitor["width"] // 2,
            "height": monitor["height"] // 2
        }

def detect_template_from_screenshot(template_img, threshold, monitor_area):
    """
    指定されたテンプレート画像が画面に存在するかを検出。

    :param template_img: 検出対象のテンプレート画像
    :param threshold: 検出閾値
    :param monitor_area: スクリーンキャプチャ領域 (Noneの場合、全画面)
    :return: True if detected, else False
    """
    with mss.mss() as sct:
        screenshot = sct.grab(monitor_area) if monitor_area else sct.grab(sct.monitors[0])
        screen_img = cv2.cvtColor(np.array(screenshot), cv2.COLOR_BGRA2BGR)

        result = cv2.matchTemplate(screen_img, template_img, cv2.TM_CCOEFF_NORMED)
        return cv2.minMaxLoc(result)[1] >= threshold

def detect_state(templates, threshold, monitor_area):
    """
    現在の状態を検出する。

    :param templates: 各状態のテンプレート画像の辞書
    :param threshold: 検出閾値
    :param monitor_area: スクリーンキャプチャ領域
    :return: 状態 ('lost_metal', 'on_field', 'in_battle', or None)
    """
    for state, img in templates.items():
        if detect_template_from_screenshot(img, threshold, monitor_area):
            return state
    return None

def press_key(key, duration=0.1):
    """キーを指定された時間押す"""
    pyautogui.keyDown(key)
    time.sleep(duration)
    pyautogui.keyUp(key)

def perform_action(state, templates, threshold, monitor_area):
    """
    検出された状態に基づいて適切なキー操作を実行。

    :param state: 検出された状態
    :param templates: 各状態のテンプレート画像の辞書
    :param threshold: 検出閾値
    :param monitor_area: スクリーンキャプチャ領域
    """
    if state == "lost_metal":
        print("はぐれメタル出現!")
        while not detect_template_from_screenshot(templates["on_field"], threshold, monitor_area):
            press_key("space")

    elif state == "in_battle":
        print("バトル中")
        for _ in range(3):
            press_key("up")
            time.sleep(0.1)
            press_key("space")

    elif state == "on_field":
        print("フィールド上")
        for _ in range(5):
            keys = random.sample(["right", "left"], 2)
            for key in keys:
                press_key(key, 0.05)

def main_loop(template_paths, interval, threshold, save_folder):
    """
    連続的に画面左上1/4をキャプチャして状態を検出し、対応する動作を実行する。

    :param template_paths: 各状態のテンプレート画像パスの辞書
    :param interval: キャプチャ間隔(秒)
    :param threshold: 検出閾値
    :param save_folder: キャプチャ画像の保存先フォルダ
    """
    templates = {state: cv2.imread(path, cv2.IMREAD_COLOR) for state, path in template_paths.items()}
    if any(img is None for img in templates.values()):
        raise ValueError("いずれかのテンプレート画像が読み込めませんでした。パスを確認してください。")

    monitor_area = get_monitor_area_for_top_left_quarter()

    print("左上1/4領域での状態検出を開始します...")

    while True:
        start_time = time.time()
        state = detect_state(templates, threshold, monitor_area)
        if state:
            perform_action(state, templates, threshold, monitor_area)

        time.sleep(max(0, interval - (time.time() - start_time)))

# 使用例
if __name__ == "__main__":
    import os

    base_path = os.path.dirname(os.path.abspath(__file__))
    template_paths = {
        "lost_metal": os.path.join(base_path, "img", "template", "lost_metal.png"),
        "on_field": os.path.join(base_path, "img", "template", "field.png"),
        "in_battle": os.path.join(base_path, "img", "template", "battle_cmd.png"),
    }
    save_folder = os.path.join(base_path, "img", "screenshots")

    main_loop(
        template_paths,
        interval=3,
        threshold=0.64,
        save_folder=save_folder
    )

save_folder は受け取っているだけで使っていません。デバッグ用にキャプチャを保存しようとした名残です(後述の改善アイデアで回収します)。

画面キャプチャ範囲を左上1/4に絞る(get_monitor_area_for_top_left_quarter)

ゲームをウィンドウモードにして画面左上1/4に置き、その領域だけをキャプチャします。フルスクリーンのままでも動きますが、範囲を絞ると良いことが2つあります。ひとつは処理が軽くなること。テンプレートマッチングの計算量は探索領域の面積にほぼ比例するので、1920×1080を960×540にするだけで面積は1/4です。もうひとつは誤検出が減ること。探索範囲にVSCodeやブラウザが映り込まないので、関係ない場所が偶然マッチする事故が起きにくくなります⚡

注意点として、sct.monitors[0] は全モニタを囲む仮想デスクトップ全体を指します。マルチディスプレイ環境ではこのwidth/heightが2画面分になってしまい、「左上1/4」がまったく意図しない領域になります。主モニタだけを対象にしたいなら sct.monitors[1] を使ってください。シングルディスプレイなら[0]でも実害はありません。

テンプレートマッチングで状態を判定する(detect_template_from_screenshot / detect_state)

心臓部です。やっていることは、テンプレート画像を探索画像の上で1ピクセルずつずらしながら類似度を計算し、最大値が閾値以上なら「あった」と判定する、それだけです。

cv2.matchTemplate の返り値は画像ではなくスコアマップ(float32の2次元配列)で、サイズは (探索画像の高さ - テンプレートの高さ + 1, 探索画像の幅 - テンプレートの幅 + 1) になります。そこから cv2.minMaxLoc で最小値・最大値・それぞれの座標を取り出します。返り値はタプル (minVal, maxVal, minLoc, maxLoc) なので、[1] が最大スコアです。

今回使っている TM_CCOEFF_NORMED は、正規化相互相関(平均を引いた版)です。擬似コードで書くと次のイメージになります。

T' = テンプレート  - テンプレートの平均
I' = 切り出した領域 - 切り出した領域の平均

score = sum(T' * I') / sqrt( sum(T'^2) * sum(I'^2) )

平均を引いてから正規化しているので、画面全体の明るさが多少変わってもスコアが安定します。値の範囲は-1〜1で、1に近いほど一致しています。

もうひとつよく使われるのが TM_SQDIFF_NORMED で、こちらは差の二乗和(SSD)を正規化したものです。

score = sum((T - I)^2) / sqrt( sum(T^2) * sum(I^2) )

⚠️ こちらは0に近いほど一致なので、判定の不等号が逆になります。手法を差し替えるときは >= threshold を <= threshold に直すのを忘れないでください。ちなみに、差の絶対値和(SAD)を使う手法は cv2.matchTemplate には用意されていません。

もう1点、誤解されがちなところ。カラー画像を渡しても内部でグレースケール化はされません。3チャンネルなら全チャンネルにわたって計算されます。つまり、探索画像とテンプレート画像のチャンネル数とデータ型が一致していないとエラーになります。今回は探索側をBGRA→BGRに変換し、テンプレート側を cv2.IMREAD_COLOR で読んでいるので、どちらも3チャンネル8bitで揃っています。透過PNGをそのまま IMREAD_UNCHANGED で読むと4チャンネルになって落ちるので注意してください。

detect_state は前述の通り、辞書の順に見て最初にヒットした状態を返します。3枚分キャプチャし直している(detect_template_from_screenshot の中で毎回grabしている)のは実装としては無駄で、本来は1回のキャプチャを使い回すべきです。ここは改善ポイントとして自覚しています。

状態ごとのキー操作を組み立てる(press_key / perform_action)

press_key は keyDown → sleep → keyUp という、ただの「押しっぱなし」実装です。pyautogui.press() を使わずこう書いているのは、押下時間を自分で指定したいからです。ゲームは入力をフレーム単位でポーリングしているので、押下時間が短すぎると取りこぼされることがあります。

使うキーはスペース(決定)と方向キーだけ。極力簡素にしてあります。参考にする場合は、自分のキーコンフィグと照らし合わせてください。

状態別の中身はこうです。

  • lost_metal では、フィールドに戻ったことが検出できるまでスペースキーを押し続けます。攻撃対象の選択もメッセージ送りも、ほぼ決定キーだけで進むからです。whileで回っているので、戦闘が終わるまでこの関数から抜けません🔨
  • in_battle は、上キー → 決定を3回。コマンドウィンドウでカーソルを上方向に動かして「にげる」に合わせ、決定する想定です。3回繰り返しているのは冗長化で、1回目でカーソル位置がずれていても、2回目3回目で吸収されることを狙っています。
  • on_field は random.sample(["right", "left"], 2) で右左の順序をランダムにして、それを5セット。結果としてその場で左右に往復するだけですが、ランダムエンカウントは歩数でカウントされるので、これで十分エンカウントします。マップ端で詰まったり、船や階段に突っ込んだりしないのも、往復移動の地味な利点です。

メインループと取りこぼしを減らす待ち時間の考え方(main_loop)

main_loop の末尾がポイントです。

time.sleep(max(0, interval - (time.time() - start_time)))

単純に time.sleep(interval) と書くと、「判定にかかった時間 + interval秒」が1周期になり、処理が重い環境ほどループ間隔が伸びてしまいます。上の書き方なら処理時間を差し引いた残りだけ待つので、周期がinterval秒に揃います。処理がintervalを超えた場合は max(0, ...) で0になり、即次のループに入ります。こういう「固定周期ループ」は監視系のスクリプトでも使い回せる定石です。

💡 もうひとつ、templates を main_loop の冒頭で一度だけ読み込み、Noneチェックをしているのも地味に重要です。cv2.imread はパスが間違っていても例外を投げずに None を返すので、チェックを入れないと「matchTemplateの中で謎のエラー」という分かりにくい落ち方をします。起動直後に落として原因をはっきりさせるのが親切です。

🛠️ テンプレート画像の作り方とパラメータの決め方

どこを切り出すか:変化しない領域を選ぶ

テンプレートマッチングの精度は、9割が「どこを切り出すか」で決まります。選ぶ基準は3つです。

  1. その状態のときだけ必ず表示される。フィールド判定なら常時出ているUI、戦闘判定ならコマンドウィンドウの枠や文字。
  2. 中身が変化しない。HPやゴールドの数字、時間で変わる天候・昼夜、キャラのアニメーションは避けます。ウィンドウの「枠」や固定ラベルの文字が狙い目です。
  3. 小さすぎず、大きすぎない。小さすぎると他の場所と偶然一致しやすくなり、大きすぎると少しの変化で一致しなくなるうえ、計算も重くなります。数十〜百数十ピクセル角くらいが扱いやすいです。

切り出し手順は単純で、自動化したい状態でゲームを止めてスクリーンショットを撮り、画像編集ソフトで目的の範囲をトリミングしてPNG保存するだけです。切り出し元は、本番と同じ解像度・同じウィンドウサイズのスクリーンショットにしてください。ここがズレると後述の「まったく検出されない」の原因になります。

閾値0.64にした経緯と、0.8が推奨と言われる理由

コード中の threshold=0.64 は、よく見かける「0.8以上推奨」からするとかなり低い値です🤔

0.8がよく推奨されるのは、TM_CCOEFF_NORMED で明らかに同じものが写っていれば0.9台が普通に出るからです。逆に言えば、0.8を割るということは「テンプレートと実際の画面に何らかの差がある」というサインでもあります。圧縮ノイズ、アンチエイリアス、拡大縮小、背景の透け、アニメーションによる微妙な差など、原因はいろいろです。

0.64という値は、筆者の環境でスコアを見ながら調整して落ち着いた数字であって、そのままコピーして使える汎用値ではありません。自分の環境では、次の手順で決めてください。

import cv2, numpy as np, mss

template = cv2.imread("img/template/field.png", cv2.IMREAD_COLOR)
area = {"top": 0, "left": 0, "width": 960, "height": 540}

with mss.mss() as sct:
    shot = cv2.cvtColor(np.array(sct.grab(area)), cv2.COLOR_BGRA2BGR)

res = cv2.matchTemplate(shot, template, cv2.TM_CCOEFF_NORMED)
print("max score:", cv2.minMaxLoc(res)[1], "loc:", cv2.minMaxLoc(res)[3])
  1. 当たってほしい画面(例:フィールド表示中)でこれを何度か実行し、最大スコアの最小値を記録する。
  2. 当たってほしくない画面(戦闘中、メニュー中など)で同じく実行し、最大スコアの最大値を記録する。
  3. その2つの間に閾値を置く。差が十分に開いていれば中間値、開いていなければテンプレートの切り出し位置を見直す。

⚠️ 「閾値を下げれば検出できる」は一見正しい解決に見えますが、下げるほど誤検出の確率も上がるトレードオフです。0.6台まで下げないと当たらない場合は、まずテンプレート側を疑うのが筋だと思っておいてください。

キャプチャ間隔(interval=3)とキー押下時間(0.05〜0.1秒)の調整ポイント

interval=3 は「3秒に1回だけ状態を見る」という、かなりゆっくりした設定です。これで成立するのは、perform_action の中で状態が終わるまで自前でループする設計にしているからです。つまり、メインループは状態の入り口を検知する役目しか負っていません。

調整の考え方は次の通りです。

パラメータ 短くすると 長くすると 目安
interval 状態変化への反応が速くなるが、CPU使用率が上がる CPUに優しいが、短時間しか出ない画面を取りこぼす 状態が画面に出ている最短時間の半分以下
press_keyのduration 入力が取りこぼされる 長押し扱いになりカーソルが飛ぶ 0.05〜0.1秒から試す
キー間のsleep メニューのアニメーションに追い越される 全体が遅くなる 0.1秒前後

特に押下時間は、ゲーム側の入力処理(60fpsなら1フレーム約16.7ms)を意識してください。0.05秒はおよそ3フレーム分です。それでも効かない場合は0.15秒くらいまで伸ばして様子を見ます。逆にメニューでカーソルが2個ずつ動いてしまうなら、キーリピートが効いている証拠なので短くします。

ゲーム画面を左上1/4に置いた開発環境レイアウト

ウィンドウ配置は開発効率のためです。画面を4分割して、左上にゲーム本体(キャプチャ対象)、左下にターミナル(printの出力で、今どの状態と判定されているかをリアルタイムに確認)、右半分にエディタ(判定がおかしければ即座に閾値やテンプレートを差し替え)という配置にしています。この「判定結果のログを常に目視できる状態」が、パラメータ調整では一番効きました。perform_action の先頭にある print("はぐれメタル出現!") のようなログは、飾りではなくデバッグの生命線です。

⚠️ なお、ゲーム側はフルスクリーンではなくウィンドウモードにしてください。多くのタイトルは設定メニューから変更できます。

開発時の画面レイアウト(左上にゲーム、左下にターミナル、右側にエディタ) 画像出典:ドラゴンクエストIII そして伝説へ…(HD-2D版) © SQUARE ENIX

🧯 つまずきどころと対処法

テンプレートが全く検出されない(解像度・スケール・色空間のズレ)

一番多いトラブルです。スコアが0.3や0.2しか出ない場合、たいてい以下のどれかです。

  • 解像度・ウィンドウサイズが変わった。テンプレートを切り出したときと実行時でウィンドウサイズが違うと、同じ絵でもピクセルサイズが変わります。matchTemplate は拡大縮小に対応していないので、1.1倍違うだけでスコアは大きく落ちます。テンプレートを取り直すか、後述のマルチスケール対応を入れます。
  • ディスプレイのスケーリング。Windowsの「拡大/縮小 150%」などが有効だと、スクリーンショットの論理座標と物理ピクセルがずれ、キャプチャが引き伸ばされることがあります。100%にして検証するのが手っ取り早いです。
  • ⚠️ 色空間の取り違え。mssが返すのはBGRAです。cv2.COLOR_BGRA2BGR を忘れると4チャンネルのままで、テンプレート(3チャンネル)と噛み合わずエラーになります。逆に、テンプレートをPillowで読んでRGBのまま渡すと、赤と青が入れ替わってスコアが激落ちします。OpenCVはBGR順です。
  • キャプチャ範囲にそもそも入っていない。monitor_area がずれていて、テンプレートの元絵が探索範囲外にいるケース。切り分けには、キャプチャ画像を cv2.imwrite("debug.png", screen_img) で保存して目視するのが最速です。

切り分けは、(1) キャプチャ画像を保存して目視 → (2) 前節のスコア表示スクリプトで数値を確認 → (3) 最大スコアの座標(maxLoc)を見て意図した位置に当たっているか確認、の順に進めると迷いません。

誤検出で意図しないキーが暴発する

閾値を下げすぎたときに起きます。フィールドではないのに on_field と判定され、戦闘中に方向キーが連打されてカーソルが飛ぶ、といった挙動です。対策は3つ。

  1. テンプレートを作り直す。文字や枠のように、コントラストがはっきりしていて他にない形を選ぶ。
  2. 探索範囲をさらに絞る。「戦闘コマンドは画面下部にしか出ない」と分かっているなら、monitor_area を状態ごとに変える手もあります。範囲が狭いほど、偶然の一致は起きにくくなります。
  3. 💡 連続ヒットを条件にする。1回の判定で即行動せず、2回連続で同じ状態と判定されたときだけ perform_action を呼ぶ。フリッカー的な誤検出をかなり潰せます。

PyAutoGUIのキー入力がゲームに届かない

スクリプトは正常に動き、ターミナルには状態が出ているのに、ゲームがピクリとも動かない。これも定番です。

  • ゲームウィンドウがアクティブでない。PyAutoGUIはアクティブウィンドウに入力を送るので、実行後にゲームをクリックしてフォーカスを当てます。スクリプト起動直後に time.sleep(5) を入れて、その間に手動でゲームをクリックする運用が確実です。
  • 管理者権限で動いているゲームに、一般権限のPythonから送れない。Windowsでは権限レベルが上のウィンドウに入力を送れません。ターミナルを管理者として実行してみてください。
  • DirectInputしか受け付けないゲーム。タイトルによっては、PyAutoGUIが使う入力方式を受け取らないことがあります。この場合はスキャンコードを直接送る系のライブラリ(pydirectinput など)への差し替えを検討します。press_key を1箇所にまとめてあるので、中身を差し替えるだけで済む構造にしてあります🔄
  • 押下時間が短すぎる。前述の通りです。まず duration を0.15秒くらいに上げて反応を見ます。

無限ループを安全に止める仕組みを入れておく

このスクリプトは while True: で回り続け、しかもキーを勝手に押します。止める手段を先に用意してから実行してください。

  • PyAutoGUIのフェイルセーフ。デフォルトで有効で、マウスカーソルを画面の左上隅に持っていくと FailSafeException が飛んで停止します。ただし、これはPyAutoGUIの関数が呼ばれたタイミングでしかチェックされません。
  • Ctrl + C。ターミナルにフォーカスを戻してから押します。main_loop を try / except KeyboardInterrupt で囲み、終了時に pyautogui.keyUp() で全キーを離してから抜けるようにしておくと、キーが押しっぱなしのまま残る事故を防げます。
  • 時間制限。main_loop に「開始からN時間経ったらbreak」を入れておくと、放置中に想定外の場所で暴れ続けるリスクを抑えられます。
  • lost_metal のwhileループに上限を設ける。現在の実装は「フィールドが検出できるまで」無限にスペースを押し続けます。何らかの理由でフィールド判定が出なくなると、永遠に抜けられません。試行回数やタイムアウトを入れておくべき箇所です。

🚀 ここから先の改善アイデア

複数解像度に対応させる(マルチスケールマッチング)

matchTemplate はスケール不変ではないので、解像度を変えるたびにテンプレートを作り直すことになります。これを避けるには、探索画像(またはテンプレート)を複数倍率でリサイズして、それぞれでマッチングし、最良スコアを採用します🔍

def match_multi_scale(screen, template, scales=(0.8, 0.9, 1.0, 1.1, 1.25)):
    best = 0.0
    for s in scales:
        resized = cv2.resize(template, None, fx=s, fy=s, interpolation=cv2.INTER_AREA)
        if resized.shape[0] > screen.shape[0] or resized.shape[1] > screen.shape[1]:
            continue
        score = cv2.minMaxLoc(cv2.matchTemplate(screen, resized, cv2.TM_CCOEFF_NORMED))[1]
        best = max(best, score)
    return best

当然、倍率の数だけ処理時間が増えます。起動時に一度だけ最適な倍率を探して固定する、という折衷案が実用的です。

グレースケール化・ROI縮小で1ループあたりの処理時間を削る

現在の実装には、明確に無駄が2つあります。

  1. 3つの状態それぞれで画面をキャプチャし直している。1回キャプチャした画像を使い回せば、単純にキャプチャ回数が1/3になります。
  2. BGR 3チャンネルのまま計算している。前述の通り、カラーのまま渡すと全チャンネルで計算されるので、単純に考えて計算量は3倍です。色情報が判定に不要なら、探索画像とテンプレートの両方を cv2.cvtColor(..., cv2.COLOR_BGR2GRAY) でグレースケール化してからマッチングすれば軽くなります(色でしか区別できない対象の場合は、逆にカラーのままのほうが安全です)。

さらに、状態ごとに探索範囲(ROI)を切り分けられれば、面積の削減がそのまま高速化と誤検出削減の両方に効きます。「戦闘コマンドは画面下1/3」「はぐメタは中央付近」のように決め打ちできるなら、やる価値は大きいです🚀

ログとスクリーンショットを残して挙動を後から追えるようにする

放置運用する以上、「寝ている間に何が起きたのか」を後から追える仕組みが要ります。print ではなく logging モジュールを使い、タイムスタンプ付きでファイルに残すだけでも全然違います。

記録しておくと役に立つのは、判定された状態・そのときの最大スコア・経過時間です。「深夜3時ごろからNoneが続いている」と分かれば、そのあたりで想定外の画面に入ったと当たりがつきます。

合わせて、detect_state が None を返したときだけキャプチャ画像を保存しておくと、未知の状態をテンプレート化する材料がそのまま手に入ります。冒頭のコードで宙に浮いていた save_folder 引数は、まさにこの用途のために置いたものでした。

if state is None:
    path = os.path.join(save_folder, f"unknown_{int(time.time())}.png")
    cv2.imwrite(path, screen_img)

🏁 まとめ:画面認識+入力自動化は「状態設計」が9割

ここまで書いてきて改めて思うのは、この手の自動化で難しいのは画像処理そのものではない、ということです。cv2.matchTemplate と pyautogui.keyDown の使い方は、ドキュメントを読めば30分で分かります📖

本当に効くのは、

  • 状態をいくつに分けるかを決めること(今回は3つ+「どれでもない」)
  • どれにも当てはまらないときは何もしない、と割り切ること
  • 失敗しても次のループで自己回復する操作列を組むこと(逃げるのに失敗しても、次の周でもう一度逃げる)
  • 各状態を一意に識別できるテンプレートを見つけること

この4点です。逆にここが雑だと、閾値をいくら細かく詰めても安定しません。閾値0.64という数字も、本質的には「テンプレートの選び方が甘かったこと」の裏返しだと思っています。

対象がオフラインのシングルプレイであること、利用規約を確認すること、止める手段を用意してから動かすこと。この3つだけ守れば、画面認識+入力自動化はかなり応用の効く技術です。ゲームに限らず、GUIしか提供されていない業務システムの定型作業にも同じ設計がそのまま使えます。まずは「常に同じ場所に出る小さなUI部品」をひとつ検出するところから試してみてください。