3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-01-29

前回「Obnizで戦車ラジコンを作ろう」で戦車ラジコンを作ったのですが、思いのほか面白かったので、拡張してみました。

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

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

image.png

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

image.png

戦闘終了後の画面です。

image.png

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

ソースコード

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

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です。

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の画像を取得

以下の部分です。

                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に登録した関数が呼ばれます。

画像取得の完了イベント

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

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

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


requestAnimationFrame(this.camera_draw);

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

canvasに取得した画像を描画

以下の部分です。


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

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

以下の部分です。


        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コード部分を青四角で囲う

以下の部分です。


                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();

大砲発射

以下の関数です。

        battle_fire: function(){

補足

コントローラを表示するページがHTTPSの場合、M5Cameraの画像もHTTPSでないといけないようです。
 混在コンテンツ - Security | MDN

以上

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?