Help us understand the problem. What is going on with this article?

MATLABによるChromeの恐竜のアレ

1. 概要

Chromeでオフライン時、あるいはアドレスバーに"chrome://dino/-109"を入力したときに遊べる、恐竜がサボテンやプテラノドンを避けながら走るゲーム「Dino Run」。
Desktop 2020.12.13 - 02.37.10.23_Trim.gif

Vivaldiでも"vivaldi://dino/-109"にアクセスすることで、同様のゲームを遊ぶことができます。
縦長Tonyくんキモすぎひん・・・?
3_Trim_Trim_Trim.gif
操作はスペースキーでジャンプ(押下時間により高さが変動)、↓キーでしゃがみ(+落下速度向上)です。

今回は、MATLABで

  • スクリーンショットを取得しゲームの状態を把握
  • ゲームの状態に従い適切なタイミングでスペースキーを押下

を行い、このゲームを自動プレイするコードを作成したので紹介します。

なお、高度に政治的な判断によりブラウザはVivaldiを使用します。

2. スクリーンショットとキーボードイベントの生成

MATLABからスクリーンショットとキーボードイベントを発生させるためには、java.awt.Robotクラスを使用します。
参考:スクリーンショット取得
参考:キーボードシミュレート

2.1 スクリーンショットの取得

スクリーンショットはjava.awt.Robot.createScreenCapture関数で取得することができます。引数にjava.awt.Rectangleで矩形領域を指定することで、ウィンドウ上の特定範囲のスクリーンショットを取得します。矩形領域はウィンドウ左上隅を基準に、lefttopheightwidthをそれぞれ指定します。
image.png

取得したスクリーンショットはgetRGBメソッドでint32型の配列として取り出すことができます。この配列はスクリーンショット取得領域を行優先で展開したもので、各1バイトごとにRGBAの値がBGRAの順で格納されます。
image.png

スクリーンショット取得用コードのサンプル
% java.awt.Robotクラスのインスタンスを定義
rbt = java.awt.Robot;
% スクリーンショット取得領域の定義
cap_left = 0;
cap_top = 0;
cap_width = 640;
cap_height = 480;
cap_rect = java.awt.Rectangle(cap_left , cap_top , cap_width , cap_height );

% スクリーンショット取得
cap = rbt.createScreenCapture(cap_rect);
% RGBA値の取得
rgb = cap.getRGB(0,0,cap.getWidth,cap.getHeight,[],0,cap.getWidth);

%% 画像の表示
% int32からuint8へキャストし各チャネルの値を分解
rgb = typecast(rgb, 'uint8');
% 画像データの作成
imgData = zeros(cap.getHeight,cap.getWidth,3,'uint8');
imgData(:,:,1) = reshape(rgb(3:4:end),cap.getWidth,[])';
imgData(:,:,2) = reshape(rgb(2:4:end),cap.getWidth,[])';
imgData(:,:,3) = reshape(rgb(1:4:end),cap.getWidth,[])';
% 画像表示
imshow(imgData);

2.2 キーボードイベントの発生

キー押下イベントはjava.awt.Robot.keyPress関数、キー解放イベントはjava.awt.Robot.keyRelease関数で発生させることができます。キーコードはjava.awt.event.KeyEvent.VK_xxxで定義されています(キーコード一覧)。

キーボードイベントのサンプルコード
% java.awt.Robotクラスのインスタンス定義
rbt = java.awt.Robot;

% スペースキーのキーコード
key_event_space = java.awt.event.KeyEvent.VK_SPACE;

% スペースキーを約0.25秒押下して解放
rbt.keyPress(key_event_space);
pause(0.25);
rbt.keyRelease(key_event_space);

3. 実装

3.1 固定タイミング、固定ジャンプ量

まずは単純に、障害物(サボテン、プテラノドン)を検出する矩形領域を事前に定めておき、この領域内に障害物が進入したときにスペースキーの押下、解放イベントを発生させます。

検出領域の高さは地面とキャラクタの身長、横位置はジャンプまでの猶予時間をみながら試行錯誤で決定しています。
Screenshot from 2020-11-23 19-20-22.png

障害物検出の方法は、検出領域内では背景色が単色であることを利用し、検出領域内に複数色があるか否かで判定します。

全コードは以下の通りです。サンプルレートは音頭を取っていないため成り行き任せです(実行環境によりジャンプタイミング等が変わります)。

実装(1)
% 環境初期化
clear;
close all;

% java.awt.Robotクラスのインスタンス
rbt = java.awt.Robot;

% スクリーンショット取得領域(障害物検出領域)
cap_width = 20;
cap_height = 35;
cap_rect = java.awt.Rectangle(800, 190, cap_width, cap_height);

% スペースキーのキーコード
key_event_space = java.awt.event.KeyEvent.VK_SPACE;

% 各種イベントはアクティブなウィンドウに対して実行されるため、
% MATLABからブラウザへアクティブウィンドウを変更するための猶予時間
pause(3);

% GAME OVER画面からの復帰(スペースキー押下)
rbt.keyPress(key_event_space);
pause(0.1);
rbt.keyRelease(key_event_space);

% メインループ
while (true)
    % スクリーンショット取得
    cap = rbt.createScreenCapture(cap_rect);
    % RGB値取得
    rgb = cap.getRGB(0,0,cap_width,cap_height,[],0,cap_width);

    % 障害物判定(取得画像が単色でなかったら)
    if ( length(unique(rgb)) ~= 1 )
        % 0.25秒間スペースキーを押下する
        rbt.keyPress(key_event_space);
        pause(0.25);
        rbt.keyRelease(key_event_space);
    end
end

実行してみた結果を以下の動画に示します。スコアは1028点、序盤は調子がいいですが、障害物間の間隔が狭い場合に次のジャンプが間に合わずゲームオーバー。

image.png
https://drive.google.com/file/d/1E8ocp7kMwT1w7-sKS0A4AfpuK2bVCfHA/view?usp=sharing

2.2 障害物の高さに応じたジャンプ量の調整

上記のように、常に大ジャンプを行っていると次のジャンプが間に合いません。そこで、障害物の背が低い場合はジャンプ量を少なくし、早期に着地できるよう調整を行います。前述のとおりスクリーンショットのRGBA配列は行優先で展開されているため、背景との境界の最小インデックスをfind(diff(rgb), 1)で求めて高さを概算できます。(嘘です。書いてて気づきましたが障害物下方の左端がギリギリ検出領域に被った場合は実際より低く算出されます。)
image.png

実装(2)
% 環境初期化
clear;
close all;

% java.awt.Robotクラスのインスタンス
rbt = java.awt.Robot;

% スクリーンショット取得領域(障害物検出領域)
cap_width = 20;
cap_height = 35;
cap_rect = java.awt.Rectangle(800, 190, cap_width, cap_height);

% スペースキーのキーコード 
key_event_space = java.awt.event.KeyEvent.VK_SPACE;

% 各種イベントはアクティブなウィンドウに対して実行されるため、
% MATLABからブラウザへアクティブウィンドウを変更するための猶予時間
pause(3);

% GAME OVER画面からの復帰(スペースキー押下)
rbt.keyPress(key_event_space);
pause(0.1);
rbt.keyRelease(key_event_space);

% メインループ
while (true)
    % スクリーンショット取得
    cap = rbt.createScreenCapture(cap_rect);
    % RGB値取得
    rgb = cap.getRGB(0,0,cap_width,cap_height,[],0,cap_width);

    % 障害物判定(取得画像が単色でなかったら)
    if ( length(unique(rgb)) ~= 1 )
        % 障害物の高さに応じてジャンプ量を調整
        if ( find(diff(rgb), 1) < 200 )
            t_jump = 0.25;
        else
            t_jump = 0.10;
        end

        % 調整されたジャンプ量の分、スペースキーを押下する
        rbt.keyPress(key_event_space);
        pause(t_jump);
        rbt.keyRelease(key_event_space);
    end
end

実行してみた結果を以下の動画に示します。スコアは1420点。大きく改善できましたが、スクロールスピードの加速に伴いジャンプのタイミングがずれてゲームオーバー。
image.png
https://drive.google.com/file/d/1U37UxrneF-dH6-jusSkMSQK0MojHb3Aw/view?usp=sharing

3.3 スクロールスピードに応じたジャンプタイミングの調整

そこでスクロールの加速に応じてジャンプを早めます。スピードの検出は時間差(0.1秒)を置いて2枚のスクリーンショットを取得し、障害物の移動距離を測ることで行います。移動距離の計算手段は相互相関やLucas-Kanade法などいろいろ考えましたが、サボテンもプテラノドンも鼻先の形状は変化しないため、やや横長のスクリーンショットを取得し、列優先で展開しなおしたRGBA配列の境界の最小インデックスの変化量を求めることでこれに代えます。
image.png
ジッタにより検出値が振れることがあったため、さらに1次LPFを適用し、求まった移動距離そのものをスクロールスピードv_filtとして扱います。適切なジャンプまでのウェイトt_jumpは、このスピードに対し線形に書けると仮定し、さらにパラメータ調整を容易にするため最序盤のスクロールスピードv_initを導入して以下のように算出します。

t_jump = -coeff_1 * (v_filt-v_init) + coeff_0

この方法は検出領域内の障害物が唯一であることを仮定しています。従って、ジャンプ処理実行済みの障害物とその次の障害物が同時に検出された場合、ジャンプ処理が二重発生し回避に失敗します。そこで、2回目以降の障害物検出処理の際は直前に取得した2枚目の障害物位置以降を検出に用います(正確には前回の障害物の後端以降とするべきですがジャンプ中にスクロールアウトするので無問題です)。
image.png

実装(3)
% 環境初期化
clear;
close all;

% java.awt.Robotクラスのインスタンス
rbt = java.awt.Robot;

% スクリーンショット取得領域(障害物検出領域) 
cap_width = 150;
cap_height = 35;
cap_rect = java.awt.Rectangle(890, 190, cap_width, cap_height);

% スペースキーのキーコード
key_event_space = java.awt.event.KeyEvent.VK_SPACE;

% ジャンプタイミング算出用の係数
coeff_1 = 0.0004;
coeff_0 = 0.5;
% 初期のスクロール速度(実測値から概算)
v_init = 1400;

% 速度推定用のフィルタ関連変数 
alpha_v = 0.7 ;
v_filt = v_init;

% 障害物検出待ちフラグ
dct_wait = true;

% 各種イベントはアクティブなウィンドウに対して実行されるため、
% MATLABからブラウザへアクティブウィンドウを変更するための猶予時間
pause(3);

% GAME OVER画面からの復帰(スペースキー押下)
rbt.keyPress(key_event_space);
pause(0.1);
rbt.keyRelease(key_event_space);
% 予測領域が横長なのでGAME OVER画面のリトライボタンと干渉するため消えるまで待機
pause(0.5);

% 同一の障害物の二重検出防止用
pos1 = 1;

% メインループ
while (true)
    % スクリーンショット取得
    cap = rbt.createScreenCapture(cap_rect);
    % RGB値取得
    rgb0 = cap.getRGB(0,0,cap_width,cap_height,[],0,cap_width);
    % 各RGB配列を列優先に変換
    rgb0cm = reshape(rgb0, cap_width, [])';
    rgb0cm = rgb0cm(:);


    % 障害物判定(取得画像のうち、前回の2枚目における障害物位置以降の部分が単色でなかったら)
    if ( length(unique(rgb0cm(pos1:end))) ~= 1 )
        % 0.1秒待機して新たなスクリーンショットを取得
        pause(0.1);
        cap = rbt.createScreenCapture(cap_rect); 
        rgb1 = cap.getRGB(0,0,cap_width,cap_height,[],0,cap_width);

        % 各RGB配列を列優先に変換
        rgb1cm = reshape(rgb1, cap_width, [])';
        rgb1cm = rgb1cm(:);

        % 2枚のスクリーンショット内での障害物の先頭位置を取得
        pos0 = find(diff(rgb0cm(pos1:end)), 1) + (pos1-1);
        pos1 = find(diff(rgb1cm), 1);

        % フィルタ処理
        v_filt = alpha_v * v_filt + (1-alpha_v) * (pos0-pos1);

        % 変位に応じてジャンプタイミングを調整
        pause(-coeff_1*(v_filt-v_init) + coeff_0);

        % 障害物の高さに応じてジャンプ量を調整
        if ( find(diff(rgb1), 1) < 200 )
            t_jump = 0.20;
        else
            t_jump = 0.10;
        end

        % 調整されたジャンプ量の分、スペースキーを押下する
        rbt.keyPress(key_event_space);
        pause(t_jump);
        rbt.keyRelease(key_event_space);
    end
end

実行してみた結果を以下の動画に示します。スコアは1976点。これ以上の改善は↓キーのしゃがみなども取り入れる必要が出てきそうです。
image.png
https://drive.google.com/file/d/1_1VlDm_SBDV_-P_-SZ42U0m6H9KLBkm7/view?usp=sharing

4. 応用

今回はゲームでしたが、もともとこれを調べていた理由はMATLABとのI/Fがないソフトとデータ授受を行うためでした。いろいろと応用(悪用)が効きそうなので機会があればお試しあれ。

ちな

プニキもやってみた。ロビンまでやりたかったけど球種判別がめんどくさくてサスペンド。機械学習系のToolboxでもうちょっと遊べるかも?

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away