WebBluetoothとGamepad APIを使ってtoioのメカナムホイールを制御する

#サマリ
[前回](https://qiita.com/tetunori_lego/items/37477c40b16f8eab384f)に引き続き、toio™️のキューブを使ったメカナムホイール制御する記事ですが、今回は下記のポイントを意識して実現した内容です。

 - ブラウザで[特定URL](https://tetunori.github.io/MechanumWheelControlWebBluetooth/)にアクセスするだけで使える
 - WebBluetoothによるキューブの**複数台**制御
 - Gamepad APIを用いて、**DUALSHOCK 4**および**Joy-Con**で操作可能
 - アナログスティック操作による真の全方位移動の実現

## 成果物
[ツール](https://tetunori.github.io/MechanumWheelControlWebBluetooth/)、[ソースコード](https://github.com/tetunori/MechanumWheelControlWebBluetooth)<img width="860" alt="screen_10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/d7a60cab-1327-cc8b-4d39-cde04ee8bbae.png">
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr"><a href="https://twitter.com/hashtag/toio?src=hash&amp;ref_src=twsrc%5Etfw">#toio</a> で作ったメカナムホイール車をブラウザから各種Gamepad(DUALSHOCK 4やJoy-Con)でアナログ操作できるようにしました。<a href="https://twitter.com/hashtag/WebBluetooth?src=hash&amp;ref_src=twsrc%5Etfw">#WebBluetooth</a> によるキューブの複数台制御とGamepad APIが技術的なポイントです。<br><br>詳しくは下記Qiita記事にて。<a href="https://t.co/jiV1dErfVG">https://t.co/jiV1dErfVG</a> <a href="https://t.co/ibSvGTufhP">pic.twitter.com/ibSvGTufhP</a></p>&mdash; Tetsunori NAKAYAMA | 中山 哲法 (@tetunori_lego) <a href="https://twitter.com/tetunori_lego/status/1216532037898620928?ref_src=twsrc%5Etfw">January 13, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
YouTube -> https://youtu.be/LS_c0v6lSh4

#技術方針
##背景
前回toio™のVisual Programming環境で手早くメカナムホイール制御を実現しましたが、キー操作だけでは気持ちよく全方位移動操作できなかったこと、より複雑な制御を実現するにはメンテナンス性に課題があったこと、などから別の手段を検討しました。
その検討の中で、WebBluetoothがWindowsでも十分に使えるようになっていたこと、Gamepad APIが優秀すぎること<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">恥ずかしながらHTML5のGamepad APIを初めて知ったのですが、超絶便利。DualShock4をBTでPCとつないだだけで、ブラウザからほとんどの機能が使えました。これってみんな知っていることなの？<br>コチラのサイト↓で動作チェックしました。<a href="https://t.co/1zsDt9kY9y">https://t.co/1zsDt9kY9y</a> <a href="https://t.co/IP00t0QvK0">pic.twitter.com/IP00t0QvK0</a></p>&mdash; Tetsunori NAKAYAMA | 中山 哲法 (@tetunori_lego) <a href="https://twitter.com/tetunori_lego/status/1210342902825545728?ref_src=twsrc%5Etfw">December 26, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
などを知り、上述の目標をたて、『URLにアクセスするだけで使える簡単ツール』をJavaScriptで作成することにしました。

##システム構成
とにかく簡単に。
<img width="691" alt="topology.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/53730dd0-7e24-5197-6f56-48d6c203e563.png">
※当方の確認環境はWindows10 vaio VJP132, Google Chrome. 特別なBTドングル等は不要！

# 準備
## Hardware
1. [前回の記事](https://qiita.com/tetunori_lego/items/37477c40b16f8eab384f)に従ってメカナムホイールカーを組み立ててください。<BR><img width="400" alt="gamepads.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/a5c431d6-0a22-d2e9-857e-ed497005a963.jpeg">
2. 2つのキューブの電源を入れておきます。
3. "DUALSHOCK 4"か"Joy-Con"(ただしL/R両方)を用意します。事前にPCのBluetooth設定でペアリングを済ませておいてください。<BR><img width="400" alt="gamepads.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/ac91c7c9-44fe-99b9-5e46-24d61d18bdd5.jpeg">

## Software
1. [こちらのツール](https://tetunori.github.io/MechanumWheelControlWebBluetooth/)を開きましょう。 WebBluetoothはまだ限られたブラウザでしか動作しないので、"Google Chrome"を使うことを強く推奨します。
2. GamepadのPS/Homeボタンを押してください。するとすぐに認識され、ツール内のGamepadアイコンが下記の様に白くアクティブになるはずです。
<img width="321" alt="105.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/2f5bda8b-1a98-70a0-5c38-9f8cab9ba999.png">

3. "CONNECT CUBE 1/2"ボタンを押すと1つずつキューブと接続することができます。
4. 接続が完了するとツールの画面が変わります。この画像になったら操作可能です！<BR><img width="323" alt="110.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/456bdb2a-fa38-726e-f82f-7b61082d2f86.png">
5. なお、Head/Tailキューブがただしく配置されているか、後述のチェック手段を参考にして確認してください。

# 画面の説明
<img width="876" alt="screen_description.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/8c7c4749-509b-a947-0fca-de420a5838fb.png">

# 操作方法
|カテゴリ|キー操作|Gamepad操作<BR>DUALSHOCK 4/Joy-Con|スクリーン/UIの例|
|---|---|---|---|
|全方位移動|↑/↓/←/→組み合わせ8方向|左analog stick<BR>(✕/B buttonを押しながらで強制的に8方向へ補正。この時フレームが赤に。)|<img width="323" alt="200.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/cea3360a-a582-d2ae-5048-2428bb759504.png">|
|中央(赤い点)を中心に回転|R + ←/→|右analog stick|<img width="320" alt="201.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/88313616-a100-9428-cfef-8e1db696aacd.png">|
|Head/tail(赤い点)を中心に回転|H/T + ←/→|↑/↓ + 右analog stick|<img width="323" alt="202.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/a97365d5-8903-8a3d-1017-cb0760bb83f1.png">|
|L/R後輪(赤い点)を中心に回転 |D/F + ←/→|←/→ + 右analog stick|<img width="323" alt="203.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/1b6e6b4c-c4d9-c969-1137-bbdf704bbdc2.png">|
|Head/Tailの交換|Q|Option/"+"ボタン|Headキューブが白く光ります。|
|設定リセット|ESC|PS/Homeボタン||
|Gamepad選択|N/A|PS/Homeボタン||
|速度調整(離散的)|"-"/"+"|L1/R1, L/Rボタン|<img width="118" alt="204.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/1073aadd-f766-13ee-cae4-36a47e94dea0.png"><BR>デフォルト値は60です。|
|速度調整(連続的)|N/A|〇 button + L2トリガー.<BR>(Joy-Con does not support this function.)|<img width="125" alt="205.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/60d58fd4-02a5-6255-967f-548399f317f2.png"><BR>もちろん直接スライダーをいじってもOK。|
## Working demo movie
YouTube -> https://youtu.be/LS_c0v6lSh4
Twitter -> https://twitter.com/tetunori_lego/status/1216532037898620928?s=20

# SWのポイント
## gamepad API
こんな感じで接続イベントを受け取れるので、Gamepadの`index`を保存しておきます。

```Javascript:接続時のイベントハンドラを登録
  window.addEventListener( "gamepadconnected", ( event ) => {
    // console.log( "Gamepad connected." );
    // console.log( event.gamepad );
    gGamePadIndex = event.gamepad.index;
  });
```

この`index`を`navigator.getGamepads()`の配列インデックスに放り込むと、各アナログスティックやボタンの状態を取得できます。

```Javascript:各種Gamepadステータスへのアクセス
    const gamePad = navigator.getGamepads()[ gGamePadIndex ];
    gamePad.axes[ GAMEPAD_LEFT_AXIS_X /* 0 */ ];
    gamePad.buttons[ GAMEPAD_BT_DOWN /* 13 */ ].value; 
```

今回のソースでは、`window.requestAnimationFrame()`毎に状態を取得しています。
なお接続はしていなくても、ペアリングしてるGamepadが接続イベント時に上がってくることがあるので、今回はすべての`Index`を内部で登録しておき、PS/Homeボタンが押された際に使用するGamepadとみなす処理を入れています。

## 全方位移動の制御ロジック
制御方向を`gArrowAngle`とした時、アナログスティックのX/Y座標を下記の様に`Math.atan2`するだけでOKです。座標系の調整で`-1`かけたりもしますが。
速さについては、これもアナログスティックから得られる値に`MaxSpeed`設定値を掛け算して`magnitude`として算出しています。あとは前回同様のロジックで各タイヤへの制御値を決定します。

```Javascript:全方位移動の制御ロジック
gArrowAngle = -1 * Math.atan2( gIS.yAxisMove, gIS.xAxisMove );

const magnitude = gMaxSpeed * 100 * Math.sqrt( gIS.xAxisMove * gIS.xAxisMove + gIS.yAxisMove * gIS.yAxisMove );

moveSpeedUpRightDownLeft = -1 * Math.round( magnitude * Math.sin( gArrowAngle + Math.PI / 4 ) );
moveSpeedUpLeftDownRight = -1 * Math.round( magnitude * Math.sin( gArrowAngle - Math.PI / 4 ) );

```

なお、✕/Bボタンを押しながらアナログスティックを動かすと、8方向に補正する処理が入っていますが、その場合は`gArrowAngle`を求める計算式が若干面倒になります。

```Javascript:全方位移動の制御ロジック：8方向へ強制的に補正
gArrowAngle = -1 * Math.round( 4 * Math.atan2( gIS.yAxisMove, gIS.xAxisMove) / Math.PI ) * Math.PI/4;
```
## WebBluetooth回り
今回は`noble`に非依存の直書きの構築としました。接続のボタン押下後、こんな感じで接続からLED/Motor制御のCharacteristicsまでシーケンシャルに取得してしまいます。

```Javascript:WebBluetoothでCube接続からCharacteristics取得まで
const SERVICE_UUID              = '10b20100-5b3b-4571-9508-cf3efcd7bbae';
const MOTOR_CHARCTERISTICS_UUID = '10b20102-5b3b-4571-9508-cf3efcd7bbae';
const LIGHT_CHARCTERISTICS_UUID = '10b20103-5b3b-4571-9508-cf3efcd7bbae';

// Scan only toio Core Cubes
const options = {
    filters: [
        { services: [ SERVICE_UUID ] },
    ],
}

navigator.bluetooth.requestDevice( options ).then( device => {
    cube.device = device;
    disableConnectCubeButton( cube );
    return device.gatt.connect();
}).then( server => {
    cube.server = server;
    return server.getPrimaryService( SERVICE_UUID );
}).then(service => {
    cube.service = service;
    return cube.service.getCharacteristic( MOTOR_CHARCTERISTICS_UUID );
}).then( characteristic => {
    cube.motorChar = characteristic;
    return cube.service.getCharacteristic( LIGHT_CHARCTERISTICS_UUID );
}).then( characteristic => {
    cube.lightChar = characteristic;
    if( ( gCubes.head !== undefined ) && ( gCubes.head.lightChar !== undefined ) && 
        ( gCubes.tail !== undefined ) && ( gCubes.tail.lightChar !== undefined ) ){
        lightHeadCube();
    }
});
```

で、実際のコマンド発行はこんな感じですね。

```Javascript:WebBluetoothでLED制御コマンド発行
const turnOnLightGreen = ( cube ) => {

    // Green light
    const buf = new Uint8Array([ 0x03, 0x00, 0x01, 0x01, 0x00, 0xFF, 0x00 ]);
    if( ( cube !== undefined ) && ( cube.lightChar !== undefined ) ){
        cube.lightChar.writeValue( buf );
    }

}
```

# 所感と考察
- アナログスティックの全方位移動はやはり気持ちが良い。実は作る前は『アナログスティックではむしろ操作が難しく、むしろ8方向に限定した方が操作しやすいのではないか？』と思っていたのだが、実際に実装して触ってみると、人間は目で見てそのフィードバックを元に細かい調整ができるため、子どもでも操作感の違いが分かるほど簡単になった。
- 操作をしていると、停止する際？にドリフトが発生してしまうことが多々あり、思い通りに操作できない瞬間がもどかしい。BTの通信や複数キューブ制御のタイミングのズレか？とデバッグしてみたもののそうではないっぽかった。メカナムホイールならではのメカ的な課題とかあるのかな？
- Gamepad APIについては、本当に超優秀。最初はDUALSHOCK 4だけで実装していて、そういえばと思って隣においてあったJoy-Conを接続してみたら、ほぼそのまま使えたのはあまりに衝撃的。接続系、特に無線回りが初めから担保されているのは強力な武器になる。単純にキューブのラジコン操作ができるツールがあったらいいなとも思った。サクッと作ろうかな。
- 今回、スピードの調整を入れたが、`MaxSpeed`60以上の早いスピードで長時間走行していると、レゴ側のゴムタイヤが削られてきて、ゴムのカスがたくさん出てしまった。キューブの中に入らないように気を付けないといけない。本機構の使用は個人の責任で～。<BR><img width="321" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542654/f02e8b0e-49e3-f585-5eb8-9bdc74cdb30a.jpeg">
- 引き続き、toio™ならではの制御として、位置座標による自動全方位移動制御も確認してみたい。