戦車ラジコン：QRコードを倒す

前回「[Obnizで戦車ラジコンを作ろう](https://qiita.com/poruruba/items/0c814783f89f42e65933 )」で戦車ラジコンを作ったのですが、思いのほか面白かったので、拡張してみました。

M5Cameraをつなげて、戦車から撮影するのですが、戦車ラジコンで徘徊している最中にQRコードを見つけて、大砲を発射（ボタンを押下）すると得点がもらえる、というものです。
時間制限を付けたので、両指でキャタピラをなんとか操作しつつ、時間内にたくさんのQRコードを破壊すれば高得点です。
ということで、前回の記事ではM5Cameraは必須としていませんでしたが、今回は必須です。

毎度の通り、GitHubに上げておきました。
　https://github.com/poruruba/obniz_motor

以下からもページを参照できます。（Obnizにつながないと使えないですが）。
　https://poruruba.github.io/obniz_motor/
※M5Cameraがhttp接続なので、https上にある上記ページからは解像度の変更ができないようです。

# 仕組み

1． HTML Imageエレメントに、M5Cameraの画像を取得します。
2． 画像取得が完了すると、イベントが発生します。
3． HTMLページ上に配置したcanvasに取得した画像を描画します。
4． それと同時に、取得した画像にQRコードが含まれるか走査します。
5． QRコードが含まれていたら、QRコード部分を青四角で囲います。

この状態(QRコードが青四角で囲われている状態)で、大砲を発射(攻撃ボタンを押下)すると、QRコードを破壊したことになります。


# 画面

Obniz接続前の画面です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/0a85c4f1-9ced-e968-f114-e23284e72041.png)

Obnizに接続後、戦闘開始前の画面です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/9a144bb9-95e9-458e-dde9-020449f7d3ff.png)

戦闘中の画面です。
残りの秒数、残りの弾数が表示されています。ちょうど、青四角で囲われているQRコードを発見しているところです。ここで「攻撃！！」ボタンを押下すると、破壊できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/b20c27a4-02e6-d59c-60e8-0352170fd777.png)

戦闘終了後の画面です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/6482f4fd-932e-e718-76c7-a5d51127b31b.png)

※ちなみに、見つけたQRコードが.mp3ファイルのURLの場合は、その音声を再生したのち、誤射とみなしちゃってます。要は減点対象です。











# ソースコード

ソースコードを示した後で、説明を付記します。
まずは、Javascriptです。

```js:start.js
'use strict';

//var vConsole = new VConsole();

let obniz;
var motor_right;
var motor_left;
var power_left_sign;
var power_right_sign;
var camera_image;

const COOKIE_EXPIRE = 365;
const POWER_MARGIN = 10;
const POWER_MAX = 40;
const TIMER_COUNT = 60.0;
const SHELL_COUNT = 10;

var vue_options = {
    el: "#top",
    data: {
        progress_title: '',

        obniz_id: '',
        obniz_connected: false,
        power_left: 0,
        power_right: 0,
        power_max: POWER_MAX + POWER_MARGIN,
        power_min: -(POWER_MAX + POWER_MARGIN),
        camera_url: 'http://192.168.1.248:81/stream',
        qrcode_context: null,
        qrcode_canvas: null,
        qrcode_list: [],
        qrcode: '',
        counter: 0.0,
        num_of_fail: 0,
        num_of_total: 0,
        shells: 0,
        lockon: false,
    },
    computed: {
    },
    methods: {
        battle_fire: function(){
            console.log('fire');
            if( this.shells <= 0 )
                return;
            
            this.shells--;
            var fire = $('#snd_fire')[0];
            fire.pause();
            fire.currentTime = 0;
            fire.play();
            if( this.lockon ){
                if( this.qrcode.endsWith('.mp3') ){
                    console.log("fail");
                    this.num_of_fail++;
                    setTimeout(() => {
                        var audioElem = new Audio();
                        audioElem.src = this.qrcode;
                        audioElem.play();
                    }, 500 );
                    return;
                }
                this.qrcode_list.push(this.qrcode);
                setTimeout(() => {
                    var bomb = $('#snd_bomb')[0];
                    bomb.pause();
                    bomb.currentTime = 0;
                    bomb.play();
                }, 700 );
            }
        },
        battle_start: function(){
            this.counter = TIMER_COUNT;
            this.shells = SHELL_COUNT;
            this.num_of_fail = 0;
            this.qrcode_list = [];
            this.lockon = false;
            this.qrcode = null;
            this.timer = setInterval(() =>{
                this.counter -= 0.1;
                if( this.counter <= 0.0){
                    clearInterval(this.timer);
                    this.counter = 0.0;
                    this.motor_reset();
                    this.num_of_total = this.qrcode_list.length - this.num_of_fail * 3;
                    if( this.num_of_total < 0)
                        this.num_of_total = 0;
//                    alert('終了ーっ！！');
                    this.dialog_open('#result_dialog', true);
                }
            }, 100);
        },
        camera_draw() {
            if(this.qrcode_canvas == null ){
//                this.qrcode_canvas = document.createElement('canvas');
                this.qrcode_canvas = $('#camera_canvas')[0];
                this.qrcode_canvas.width = camera_image.width;
                this.qrcode_canvas.height = camera_image.height;
                this.qrcode_context = this.qrcode_canvas.getContext('2d');
                this.qrcode_context.strokeStyle = "blue";
                this.qrcode_context.lineWidth = 3;
            }

            this.qrcode_context.drawImage(camera_image, 0, 0, this.qrcode_canvas.width, this.qrcode_canvas.height);

            if( this.counter > 0.0 ){
                const imageData = this.qrcode_context.getImageData(0, 0, this.qrcode_canvas.width, this.qrcode_canvas.height);
                const code = jsQR(imageData.data, this.qrcode_canvas.width, this.qrcode_canvas.height);
                if( code && code.data != "" ){
                    console.log(code);
                    if( this.qrcode_list.indexOf(code.data) < 0){
                        this.qrcode = code.data;
                        this.lockon = true;

                        var pos = code.location;
                        this.qrcode_context.beginPath();
                        this.qrcode_context.moveTo(pos.topLeftCorner.x, pos.topLeftCorner.y);
                        this.qrcode_context.lineTo(pos.topRightCorner.x, pos.topRightCorner.y);
                        this.qrcode_context.lineTo(pos.bottomRightCorner.x, pos.bottomRightCorner.y);
                        this.qrcode_context.lineTo(pos.bottomLeftCorner.x, pos.bottomLeftCorner.y);
                        this.qrcode_context.lineTo(pos.topLeftCorner.x, pos.topLeftCorner.y);
                        this.qrcode_context.stroke();
                    }else{
                        this.lockon = false;
                        this.qrcode = null;
                    }
                }else{
                    this.lockon = false;
                    this.qrcode = null;
                }
            }
        
            requestAnimationFrame(this.camera_draw);
        },
        obniz_connect: function(){
            obniz = new Obniz(this.obniz_id);
            this.progress_open('接続試行中です。', true);

            obniz.onconnect = async () => {
                this.progress_close();
                Cookies.set('obniz_id', this.obniz_id, { expires: COOKIE_EXPIRE });
                this.obniz_connected = true;

                camera_image = new Image();
                camera_image.crossOrigin = "Anonymous";
                camera_image.addEventListener("load", this.camera_draw, false);
                camera_image.src = this.camera_url;

                motor_left = obniz.wired("DCMotor", {forward:0, back:1});
                motor_right = obniz.wired("DCMotor", {forward:2, back:3});

                this.motor_reset();
            }
        },
        motor_reset: function(){
            if( this.obniz_connected ){
                motor_right.power(0);
                motor_right.move(true);
                motor_left.power(0);
                motor_left.move(true);
            }

            power_right_sign = 0;
            this.power_right = 0;
            power_left_sign = 0;
            this.power_left = 0;
        },
        motor_change_right: function(){
            if( !this.obniz_connected ){
                this.power_right = 0;
                return;
            }

            var sign = Math.sign(this.power_right);
            var power = Math.abs(this.power_right);
            if( power <= POWER_MARGIN )
                power = 0;
            else
                power -= POWER_MARGIN;

            motor_right.power(power);
            if( sign != power_right_sign ){
                motor_right.move(sign >= 0);
                power_right_sign = sign;
            }
        },
        motor_change_left: function(){
            if( !this.obniz_connected ){
                this.power_left = 0;
                return;
            }

            var sign = Math.sign(this.power_left);
            var power = Math.abs(this.power_left);
            if( power <= POWER_MARGIN )
                power = 0;
            else
                power -= POWER_MARGIN;

            motor_left.power(power);
            if( sign != power_left_sign ){
                motor_left.move(sign >= 0);
                power_left_sign = sign;
            }
        },
        motor_end_right: function(){
            if( !this.obniz_connected ){
                this.power_right = 0;
                return;
            }

            motor_right.power(0);
            motor_right.move(true);
            power_right_sign = 0;
            this.power_right = 0;
        },
        motor_end_left: function(){
            if( !this.obniz_connected ){
                this.power_left = 0;
                return;
            }
            motor_left.power(0);
            motor_left.move(true);
            power_left_sign = 0;
            this.power_left = 0;
        },
    },
    created: function(){
    },
    mounted: function(){
        proc_load();

        this.obniz_id = Cookies.get('obniz_id');
    }
};
vue_add_methods(vue_options, methods_utils);
var vue = new Vue( vue_options );
```

HTMLです。

```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
    <meta name="format-detection" content="telephone=no">
    <meta name="msapplication-tap-highlight" content="no">
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">

    <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
    <!-- Latest compiled and minified CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <!-- Optional theme -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css" integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
    <!-- Latest compiled and minified JavaScript -->
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>

    <title>Obnizラジコン</title>

    <script src="js/methods_utils.js"></script>
    <script src="js/vue_utils.js"></script>

    <script src="dist/js/vconsole.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>

    <script src="https://unpkg.com/obniz/obniz.js"></script>
    <link rel="stylesheet" href="./css/index.css" />
    <script src="dist/js/jsQR.js"></script>
</head>

<body>
    <div id="top" class="container-fluid">
        <div class="form-inline" v-if="!obniz_connected" >
            <h1>Obnizラジコン</h1>
            <button class="btn btn-default btn-sm" v-on:click="obniz_connect">接続</button>&nbsp;
            <label>obniz id</label>
            <input type="text" class="form-control" v-model="obniz_id">
            <label>camera url</label>
            <input type="text" class="form-control" v-model="camera_url">
        </div>

        <div class="controller">
            <div class="control" style="float: left;">
                <center><label>Left</label> {{power_left}}</center>
                <input id="motor_left" type="range" v-bind:min="power_min" v-bind:max="power_max" v-model.number="power_left" v-on:input="motor_change_left()" v-on:touchend="motor_end_left()"><br>
            </div>
            <div class="control" style="float: right;">
                <center><label>Right</label> {{power_right}}</center>
                <input id="motor_right" type="range" v-bind:min="power_min" v-bind:max="power_max" v-model.number="power_right" v-on:input="motor_change_right()" v-on:touchend="motor_end_right()"><br>
            </div>
        </div>
        <!--
        <img v-show="obniz_connected" style="margin:0 auto;" class="img-responsive" id="camera_image" crossorigin="anonymous" v-on:load="camera_draw">
        -->
        <canvas v-show="obniz_connected" style="margin:0 auto;" class="img-responsive" id="camera_canvas"></canvas>
        <div v-if="obniz_connected">
            <button v-if="counter==0.0" class="btn btn-primary btn-lg center-block" v-on:click="battle_start">戦闘開始</button>
            <button v-if="counter>0.0" class="btn btn-default btn-lg center-block" v-on:click="battle_fire">攻撃！！</button>
            <div v-if="counter>0.0" class="text-center"><h2><small>残り</small> {{counter.toFixed(1)}} <small>s (弾数：{{shells}})</small></h2></div>
        </div>

        <div class="modal fade" id="result_dialog">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        戦闘結果
                        <span class="pull-right">
                            <button class="btn btn-default btn-sm" v-on:click="dialog_close('#result_dialog')">閉じる</button>
                        </span>
                    </div>
                    <div class="modal-body">
                        <center>
                            <h3>敵:{{qrcode_list.length}} 誤:{{num_of_fail}}</h3>
                            <h1>今回の戦績： {{num_of_total}} <small>点</small></h1>
                            <br>
                        </center>
                    </div>
                </div>
            </div>
        </div>          

        <div class="modal fade" id="progress">
            <div class="modal-dialog">
                <div class="modal-content">
                    <div class="modal-header">
                        <h4 class="modal-title">{{progress_title}}</h4>
                    </div>
                    <div class="modal-body">
                        <center><progress max="100" /></center>
                    </div>
                </div>
            </div>
        </div>        
    </div>

    <audio src="./raw/bomb.mp3" id="snd_bomb" preload></audio>
    <audio src="./raw/fire.mp3" id="snd_fire" preload></audio>
    <script src="js/start.js"></script>
</body>
</html>
```

その他、細かなソースはGitHubを参照してください。

# 解説

## HTML ImageエレメントにM5Cameraの画像を取得

以下の部分です。

```js
                camera_image = new Image();
                camera_image.crossOrigin = "Anonymous";
                camera_image.addEventListener("load", this.camera_draw, false);
                camera_image.src = this.camera_url;
```

HTMLページ上にimgエレメントを配置せずに、newしているのは、実際にHTMLページに表示するのは取得した画像ではなく、QRコードを青四角で囲った画像を描画したかったためです。
crossOriginにAnonymousを設定しています。M5Cameraの画像の取得元と、本ページのドメインが異なるためです。M5Cameraの画像には、Access-Control-Allow-Originを”*”としてくれているおかげで、世に言う汚染から免れます。
srcにURLを設定した瞬間から、画像の取得が完了したときにaddEventListenerに登録した関数が呼ばれます。

## 画像取得の完了イベント

以下で指定した関数です。

```js
camera_image.addEventListener("load", this.camera_draw, false);
```

さらに、関数の中で以下を呼んでいます。

```js

requestAnimationFrame(this.camera_draw);
```

これは、次のフレームでまた呼ばれるようにするためです。
M5Cameraで取得される画像は、 multipart/x-mixed-replace と呼ばれる連続した画像データです。画像が次から次へと受信されます。

## canvasに取得した画像を描画

以下の部分です。

```js

  this.qrcode_context.drawImage(camera_image, 0, 0, this.qrcode_canvas.width, this.qrcode_canvas.height);
```

## 取得した画像にQRコードが含まれるか走査

以下の部分です。

```js

        const imageData = this.qrcode_context.getImageData(0, 0, this.qrcode_canvas.width, this.qrcode_canvas.height);
        const code = jsQR(imageData.data, this.qrcode_canvas.width, this.qrcode_canvas.height);
        if( code && code.data != "" ){
```

QRコードの操作には、以下のライブラリを使わせていただきました。

cozmo/jsQR
　https://github.com/cozmo/jsQR 

## QRコード部分を青四角で囲う

以下の部分です。

```js

                var pos = code.location;
                this.qrcode_context.beginPath();
                this.qrcode_context.moveTo(pos.topLeftCorner.x, pos.topLeftCorner.y);
                this.qrcode_context.lineTo(pos.topRightCorner.x, pos.topRightCorner.y);
                this.qrcode_context.lineTo(pos.bottomRightCorner.x, pos.bottomRightCorner.y);
                this.qrcode_context.lineTo(pos.bottomLeftCorner.x, pos.bottomLeftCorner.y);
                this.qrcode_context.lineTo(pos.topLeftCorner.x, pos.topLeftCorner.y);
                this.qrcode_context.stroke();
```

## 大砲発射

以下の関数です。

```js
        battle_fire: function(){
```

# 補足

コントローラを表示するページがHTTPSの場合、M5Cameraの画像もHTTPSでないといけないようです。
　[混在コンテンツ - Security | MDN](https://developer.mozilla.org/ja/docs/Security/%E6%B7%B7%E5%9C%A8%E3%82%B3%E3%83%B3%E3%83%86%E3%83%B3%E3%83%84)

以上