C# - Flashをウィンドウメッセージで操作する



●環境
Windows 7 Pro 64bit + VC# 2015 + IE11
アプリは.NET Framework 4.6.1


●発端
Clicker HeroesというFlashゲームの非アクティブ時自動操作をしたい
→UWSCで容易に可能だがC#でもできないだろうか?
→この機会にウィンドウメッセージ(とWinAPI)を学ぼう!


●お勉強
ここら辺の知識については調べりゃいくらでも出るしここでは雑に触れる程度にする。

ユーザーがアプリケーションに対して操作を行うと(操作の申請)、Windowsが間に入ってアプリケーションにウィンドウメッセージを発行する(実際の操作がここで行われる)。
ウィンドウメッセージには送信先ウィンドウのハンドル(後述), メッセージの種別, パラメータが含まれている。

どのアプリケーションに対して操作をするのかを識別するために、Windowsがアプリケーションに対して自動的に割り振るIDをウィンドウハンドルと言う。
1ウィンドウに対して1ハンドルというわけではなく、ウィンドウに付随するコントロール(ボタンとか)にも割り振られている。
そのため、ボタンがいっぱいあるアプリケーション(例えば電卓)だとそれだけ多くのウィンドウハンドルが割り振られることになる。

先ほどボタンなどを"ウィンドウに付随するコントロール"と呼んだが、これはウィンドウに対して子ウィンドウと見なすことができる。
事実上、ウィンドウには親子関係というものが存在する。親子関係が存在するということは、ウィンドウはツリー構造を作れるということになる。
ここで具体的に言うなら、FlashPlayerはIEに対して子ウィンドウである。

さて、ウィンドウメッセージを送信するためのWinAPI関数であるSendMessageが用意されている。
これを利用すれば操作の申請を行わずとも、アプリケーションを直接操作できる。
FlashPlayerのハンドルを入手して勝手にメッセージを送ろう、というのが今回の目的。

余談: IntPtrはポインタ"長"の整数型。


●ハンドル取得からメッセージ送信まで
(1) IEのウィンドウハンドルを取得する
ウィンドウハンドルを取得するにはWinAPIのFindWindowExを利用する。
しかし、FindWindowExが取得できるのは直接の関係を持つ子ウィンドウのみで、孫ウィンドウの取得は行えない。
// IEのウィンドウハンドルの取得
var ieHandle = NativeMethods.FindWindowEx(IntPtr.Zero, IntPtr.Zero, "IEFrame"IntPtr.Zero);
NativeMethodsはアンマネージコードの宣言を集約してる静的クラス

(2) FlashPlayerのウィンドウハンドルを取得する
Spy++によるウィンドウ検索

これを見ていただければ分かるかと思うが、目的であるFlashPlayerまでそれなりに深い。
その上、FindWindowExは複数の子ウィンドウを列挙するのに不適切なので、EnumChildWindowsを使用する。
※埋め込まれているflashを複数開いている場合に意図しないハンドルを取得してしまう可能性があるけど今回は無視します

static class FlashHandle
{
    static readonly string FlashHandleString = "MacromediaFlashPlayerActiveX";
    static List<IntPtr> handles = new List<IntPtr>();
 
    public static IntPtr Get()
    {
    // IEのウィンドウハンドルの取得
    var ieHandle = NativeMethods.FindWindowEx(IntPtr.Zero, IntPtr.Zero, "IEFrame"IntPtr.Zero);
 
    // IEの子ウィンドウのハンドルを列挙
    NativeMethods.EnumChildWindows(ieHandle, EnumWindowProc, IntPtr.Zero);
 
        // FlashPlayerのウィンドウハンドルの取得
        foreach(var handle in handles)
        {
            var sb = new StringBuilder(256);
            NativeMethods.GetClassName(handle, sb, sb.Capacity);
            if (sb.ToString() == FlashHandleString) return handle;
        }
        return IntPtr.Zero;
    }
 
    static bool EnumWindowProc(IntPtr handle, IntPtr lParam)
    {
        handles.Add(handle);
        return true;
    }
}
// FlashPlayerハンドルの取得
var flashHandle = FlashHandle.Get();


(3) FlashPlayerにメッセージを送信する
e.g. 50ミリ秒毎にFlashの座標(700, 400)を左クリックする
class MessageSender
{
    const int MK_LBUTTON = 0x0001;
    const int WM_LBUTTONDOWN = 0x0201;
    const int WM_LBUTTONUP = 0x0202;
 
    IntPtr windowHandle;
 
    public MessageSender(IntPtr windowHandle)
    {
        this.windowHandle = windowHandle;
    }
 
    public void Click(uint xPos, uint yPos)
    {
        var lParam = (xPos & 0xFFFF) | (yPos << 16);
        NativeMethods.SendMessage(windowHandle, WM_LBUTTONDOWN, MK_LBUTTON, lParam);
        NativeMethods.SendMessage(windowHandle, WM_LBUTTONUP, 0x00000000, lParam);
    }
}
var messageSender = new MessageSender(flashHandle);
while (true)
{
    messageSender.Click(700, 400);
    await Task.Delay(50);
}


●キー入力
自動でDRコンボを行いたい場合、対象スキルに対応したキーを入力する必要がある。
これは上述のSendMessageを使ってWM_KEYDOWN(キーが押された時に送られるメッセージ)を送信すればいい。
const int WM_KEYDOWN = 0x0100;

public void Key(KeyCode key)
{
    var wParam = (uint)key;
    NativeMethods.SendMessage(windowHandle, WM_KEYDOWN, wParam, (uint)IntPtr.Zero);
}

入力したいキーコードはenumで定義しておくと利用しやすい。(メインキーボードの1-9に対応するもの)
もちろんKeys列挙体でもok(場合によってはintにキャストする必要がありめんどくさい)
enum KeyCode : uint
{
    VK_KEY_1 = 0x31,
    VK_KEY_2,
    VK_KEY_3,
    VK_KEY_4,
    VK_KEY_5,
    VK_KEY_6,
    VK_KEY_7,
    VK_KEY_8,
    VK_KEY_9,
}


●画像認識(テンプレートマッチング)
OpenCVSharpを使います。(nugetからプロジェクトにインストール)

(1) FlashPlayerのスクリーンキャプチャ
GetClientRectを利用してFlashPlayerのサイズを取得する
PrintWindowを利用してスクリーンキャプチャする
→CvtColorでキャプチャしたスクリーンショットをグレースケールに変換する(理由は後述)
public static IplImage GrayCapture(IntPtr handle)
{
    var rect = new Native.NativeMethods.RECT();
    Native.NativeMethods.GetClientRect(handle, out rect);
 
    using (var img = new Bitmap(rect.right, rect.bottom, PixelFormat.Format32bppRgb))
    {
        using (var memg = Graphics.FromImage(img))
        {
            var dc = memg.GetHdc();
            Native.NativeMethods.PrintWindow(handle, dc, 0);
            memg.ReleaseHdc(dc);
        }
        using (var screenImage = BitmapConverter.ToIplImage(img))
        {
            var screenGrayImage = Cv.CreateImage(new CvSize(img.Width, img.Height), BitDepth.U8, 1);
            Cv.CvtColor(screenImage, screenGrayImage, ColorConversion.BgrToGray);
            return screenGrayImage;
        }
    }
}

OpenCVSharpを用いたテンプレートマッチングの際、MatchTemplateという関数を使うことになるが注意しないと以下のようなエラーを吐く。
(img.depth() == CV_8U || img.depth() == CV_32F) && img.type() == templ.type()
被探索画像のビット深度(1チャンネルの値を何ビットで表すかの値)をCV_8UかCV_32Fにして、被探索画像と探索画像の画像タイプを一致させろ、というもの。
つまり画素の型を一致させた上にチャンネル数も一致させる必要があるのだが、どちらかに型を合わせるという処理はめんどくさいので、両方をグレースケールに変換して無理やり合わせてしまうことにする。

※PrintWindowはIEのウィンドウサイズが小さく対象ウィンドウの全てを表示できない場合などにはその部分を黒塗りで補完する。
また、最小化時にはそれすら行わずキャプチャできない。
本当ならウィンドウの状態をチェックしたりしないといけないのですがこれでも目的は果たせるので今回は無視します。


(2) テンプレートマッチング
MatchTemplateでテンプレートマッチングを行い、結果をCvMat型変数に格納する
→MinMaxLocで結果を取り出す
protected bool Match(IplImage screenImage, IplImage tmplImage)
{
    using (var result = new CvMat(screenImage.Height - tmplImage.Height + 1, screenImage.Width - tmplImage.Width + 1, MatrixType.F32C1))
    {
        Cv.MatchTemplate(screenImage, tmplImage, result, MatchTemplateMethod.CCoeffNormed);
        double minVal;
        double maxVal;
        var minPoint = new CvPoint();
        var maxPoint = new CvPoint();
        // Val = 相関係数, max/min = 最大/最小の相関係数または相関係数の座標を格納する
        Cv.MinMaxLoc(result, out minVal, out maxVal, out minPoint, out maxPoint);
 
        // 0.73, 0.82, 0.93あたりの値は確認済み, 暫定的に閾値を0.7にしておく
        if (maxVal > 0.7)
        {
            X = (uint)maxPoint.X;
            Y = (uint)maxPoint.Y;
            return true;
        }
        else
        {
            X = default(uint);
            Y = default(uint);
            return false;
        }
    }
}
※探索画像もグレースケール変換してからこのメソッドに渡します

利用側
while (true)
{
    using (var screen = ScreenCapture.GrayCapture(flashHandle))
    {
        var recognizers = new ImageRecognitionBase[] 
        {
            new Clickable(),
        };
        foreach(var recognizer in recognizers)
        {
            if (recognizer.Check(screen)) sender.Click(recognizer.X, recognizer.Y);
        }
    }
    Thread.Sleep(15000);
}


●ホットキー
当アプリではF2で開始/停止を切り替えることにする。
ホットキーの登録にはRegisterHotKeyを、解除にはUnregisterHotKeyを利用する。

後でウィンドウメッセージの判別をするときに必要なメッセージID(WM_HOTKEY)と、ホットキーの識別子(0x0000-0xBFFFの範囲で任意の物が使用可能)を定義しておく。
const int WM_HOTKEY = 0x0312;
const int HOTKEY_ID = 0x0001;

ホットキーの登録をメインフォームのコンストラクタで行うことにした。
第3引数であるfsModifiersにはAltキー, Ctrlキー, Shiftキー, Winキーのどれかまたは組み合わせて指定することができるが、特に必要ない場合は0を指定すると無効になる。
public MainForm()
{
    // ホットキーの登録
    Native.NativeMethods.RegisterHotKey(this.Handle, HOTKEY_ID, 0, (int)Keys.F2);
}

登録済みのホットキーが入力されるとWM_HOTKEYのメッセージがフォームに送られるので、それをWndProc内で判別する必要がある。
protected override void WndProc(ref Message m)
{
    base.WndProc(ref m);
 
    if (m.Msg == WM_HOTKEY)
    {
        if ((int)m.WParam == HOTKEY_ID)
        {
            // ホットキーが入力された時の処理
            if (client == null) startButton.PerformClick();
            else stopButton.PerformClick();
        }
    }
}

ホットキーの解除をFormClosedイベントで行うことにした。絶対に忘れないように。
private void MainForm_FormClosed(object sender, FormClosedEventArgs e)
{
    // ホットキーの解除
    Native.NativeMethods.UnregisterHotKey(this.Handle, HOTKEY_ID);
}


●完成
とても雑ながらアセンブリを一応配布しておきます => ダウンロード


●落穂拾わない
GitHubに簡単に書いた奴を置いておきました。実装の転記を大きくサボったのでこちらを見なければ理解できないと思います。
・例外処理とWinAPIの宣言は省きました
・他に送信したいメッセージを追加したかったらMessageSenderに追加したり継承したりするだけ
画像認識もここで扱おうと思ったのですが、画面がなんらかの理由で隠れているときのクリック座標指定が上手く行かないので忘れた頃にやります
 →めちゃくちゃ雑にやりました



2016/05/25 作成
2016/05/29 一部書き直しと画像認識
2016/06/07 キー入力
2016/06/16 ホットキーの追加と1.1.0.0の配布


トップに戻る