戦車ラジコン：ブラウザ＋GamePadで動かす

最近、[戦車ラジコンネタ](https://qiita.com/poruruba/items/0c814783f89f42e65933) が続いていますが、今回は、ブラウザ＋マルチタッチで戦車を動かすのではなく、ブラウザ＋GamePadで動かしてみます。
ですので、スマホからではなく、PCから操作できるようになります。
HTML5のGamePadを使いますので、なんらかのゲームコントローラが必要です。私の場合は、SONYのDUALSHOCK 4を使いました。

![a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/8783aa74-850f-40b9-6bdb-63adc4cf622a.png)

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

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

#  HTML5 Gamepad API

ブラウザから、PCに接続されたゲームコントローラを使うためのAPIです。

Gamepad APIを使用したコントロールの実装
　https://developer.mozilla.org/ja/docs/Games/Techniques/Controls_Gamepad_API

ゲームパッドでブラウザゲームを操作できるGamepad APIが楽しい【俺聞け11】
　https://mzsm.me/2015/02/14/orekike11-gamepadapi/

SONYのDUALSHOCK 4をUSB接続したら認識してくれました。
ボタンや十字キーだけでなく、アナログジョイスティックにも対応しています。

基本的には、requestAnimationFrame で呼ばれるコールバック関数の中で、Gamepadの状態を参照します。
今回の実装では、関数check_gemapadがそれに該当し、その部分のソースを抜粋します。

```js:start.js
        check_gamepad: function(playing){
            var gamepadList = navigator.getGamepads();
            for(var i=0; i<gamepadList.length; i++){
                var gamepad = gamepadList[i];
                if(gamepad){
                    if( playing ){
                        this.power_right = Math.floor(-gamepad.axes[3] * this.power_max);
                        this.power_left = Math.floor(-gamepad.axes[1] * this.power_max);
                        this.motor_change_right();
                        this.motor_change_left();

                        if( !button_pressed && gamepad.buttons[0].pressed ){
                            button_pressed = true;
                            this.battle_fire();
                        }else if( button_pressed && !gamepad.buttons[0].pressed ){
                            button_pressed = false;
                        }
                    }else{
                        if( gamepad.buttons[9].pressed )
                            this.battle_start();
                        if( gamepad.buttons[1].pressed )
                            this.dialog_close('#result_dialog');
                    }

                    break;
                }
            }
        },
```

ループを回しているのは、gamepadList[i] がundefined となっているものが含まれているためです。
一番最初に発見したコントローラを採用しています。

アナログジョイスティックは、 gamepad.axes[] にあります。
各ボタンは、 gamepad.buttons[]の配列にあるので、どのボタンが何番目になるかは、 実際に押して試してみてください。
ちなみに、DUALSHOCK 4では以下でした。

　・0:  ×ボタン
　・1: 〇ボタン
　・9: OPTIONSボタン

# M5Cameraで取得できる画像解像度の変更

PCでも操作できるようにることにしたので、大きな解像度で表示させたいところです。
そこで、画像解像度を変更できるようにしました。
以下のようなGET呼び出しをすれば、(少しウェイトを挟んで)以降取得される画像の解像度が変更されます。

　```http://[M5CameraのIPアドレス]:80/control?var=framesize&val=X```

Xの部分には、解像度に合わせて以下の数字を指定します。
　・UXGA(1600x1200) : 10</option>
　・SXGA(1280x1024) : 9
　・XGA(1024x768) : 8
　・SVGA(800x600) : 7
　・VGA(640x480) : 6
　・CIF(400x296) : 5
　・QVGA(320x240) : 4
　・XQVGA(240x176) : 3
　・QQVGA(160x120) : 0

ちなみに、画像は以下のURLなので、Webページから入力してもらうのはM5CameraのIPアドレスだけに変更しました。

　```http://[M5CameraのIPアドレス]:81/stream```

# ソースコード

Javascriptのソースコードを載せておきます。その他のソースコードは、GitHubを参照してください。

```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;
var button_pressed = false;

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_ipaddress: '192.168.1.248',
        camera_resolution: 6,
        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);
        },
        check_gamepad: function(playing){
            var gamepadList = navigator.getGamepads();
            for(var i=0; i<gamepadList.length; i++){
                var gamepad = gamepadList[i];
                if(gamepad){
                    if( playing ){
                        this.power_right = Math.floor(-gamepad.axes[3] * this.power_max);
                        this.power_left = Math.floor(-gamepad.axes[1] * this.power_max);
                        this.motor_change_right();
                        this.motor_change_left();

                        if( !button_pressed && gamepad.buttons[0].pressed ){
                            button_pressed = true;
                            this.battle_fire();
                        }else if( button_pressed && !gamepad.buttons[0].pressed ){
                            button_pressed = false;
                        }
                    }else{
                        if( gamepad.buttons[9].pressed )
                            this.battle_start();
                        if( gamepad.buttons[1].pressed )
                            this.dialog_close('#result_dialog');
                    }

                    break;
                }
            }
        },
        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;
                }

                this.check_gamepad(true);
            }else{
                this.check_gamepad(false);
            }

            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;

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

                await do_get('http://' + this.camera_ipaddress + ':80/control', { var: 'framesize', val: this.camera_resolution });

                setTimeout(() => {
                    camera_image = new Image();
                    camera_image.crossOrigin = "Anonymous";
                    camera_image.addEventListener("load", this.camera_draw, false);
                    camera_image.src = 'http://' + this.camera_ipaddress + ':81/stream';
                }, 1000);
            }
        },
        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 );

function do_get(url, qs) {
    var params = new URLSearchParams(qs);
    var url2 = new URL(url);
    url2.search = params;
  
    return fetch(url2.toString(), {
        method: 'GET',
      })
      .then((response) => {
        if (!response.ok)
          throw 'status is not 200';
      });
  }
```

以上