ゲームコントローラーでCesiumを操作

[ホリパッド TURBO for Nintendo Switch&trade; / PC](https://hori.jp/products/nsw/nsw-488/) を買いました。PCにも使えるということで、PCにつないでみました。マウスの代わりには使えないんですね。Xboxのコントローラーとして認識されました。
ブラウザでもJavascriptを書けば使えるということなので3D地図のプラットフォームのCesiumで試しました。結果は、試したCesiumで使えました。ボタンにどの機能を割り当てるかはちゃんと考えないといけないというのが実感できました。

# 環境

- Windows 10
- Microsoft Edge
- [ホリパッド TURBO for Nintendo Switch&trade; / PC](https://hori.jp/products/nsw/nsw-488/) 

:::note warn
[ゲームパッド API の使用](https://developer.mozilla.org/ja/docs/Web/API/Gamepad_API/Using_the_Gamepad_API)によると Gamepad API はブラウザによって対応が若干ことなるようなので、他のブラウザでは動かないかもしれません。Edgeでは動きましたが、そのうち動かなくなるかもしれません。

コードはエラーチェックとかいろいろ手を抜いていることをご了承ください。
:::

# 参考にしたページ

## Cesium JS

https://cesium.com/learn/cesiumjs-learn/cesiumjs-quickstart/

https://github.com/CesiumGS/cesium/blob/main/Apps/HelloWorld.html

## Gamepad API

https://developer.mozilla.org/ja/docs/Web/API/Gamepad_API/Using_the_Gamepad_API

https://github.com/luser/gamepadtest/blob/master/gamepadtest.js

https://github.com/luser/gamepadtest

## ホリパッド TURBO

[ホリパッド TURBO for Nintendo Switch&trade; / PC 取扱説明書](https://hori.jp/image/2023/10/manual_231003.pdf)

# Gamepad API と ホリパッド TURBO の対応

ホリパッド TURBO for Nintendo Switch&trade; / PC の Gamepad API のボタン(buttons)と軸(axes)の対応。(個人調べ)

ボタンは16個認識されました。でもキャプチャーボタンは、PC 使用不可と取扱説明書に書いてあったとおり認識されませんでした。

|ボタン#|ホリパッドTURBOボタン|
|---|---|
|  0|  A|
|  1|  B|
|  2|  X|
|  3|  Y|
|  4|  L|
|  5|  R|
|  6| ZL|
|  7| ZR|
|  8| －|
|  9| ＋|
| 10|Lスティック|
| 11|Rスティック|
| 12|十字ボタン 上|
| 13|十字ボタン 下|
| 14|十字ボタン 左|
| 15|十字ボタン 右|
| 16|HOME|

LとRのスティックも認識されましたが、上下と左右で別々に認識されるんですね。

|軸#|ホリパッドTURBOスティック|
|---|---|
|  0|Lスティック 左右 -1.0: 左、+1.0: 右|
|  1|Lスティック 上下 -1.0: 上、+1.0: 下|
|  2|Rスティック 左右 -1.0: 左、+1.0: 右|
|  3|Rスティック 上下 -1.0: 上、+1.0: 下|

Gamepad API では、この「ボタン#」と「軸#」を使って識別しています。

# Javascriptのコード

そんなに長くならなかったのでindex.htmlに全部入れたのを載せます。

地図は、国土地理院の[地理院タイル 電子国土基本図（オルソ画像）](https://maps.gsi.go.jp/development/ichiran.html) を使いましたが特に深い意味はないです。素直に、Cesium Ion のを使っても見れると思います。

```html:index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <title>cesium</title>
  <link rel="icon" href="data:,">
  <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no">
  <script src="https://cesium.com/downloads/cesiumjs/releases/1.124/Build/Cesium/Cesium.js"></script>
  <link href="https://cesium.com/downloads/cesiumjs/releases/1.124/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
  <style>
    html,
    body,
    #cesiumContainer {
      width: 100%;
      height: 100%;
      margin: 0;
      padding: 0;
      overflow: hidden;
    }
  </style>
</head>

<body>
  <div id="cesiumContainer"></div>
  <script type="module">
    // Cesium
    // https://cesium.com/learn/cesiumjs-learn/cesiumjs-quickstart/

    // Cesium Ion のアクセストークンを設定する
    // Cesium.Ion.defaultAccessToken = 'your_access_token';

    // ビューのホームとして富士山を設定する
    Cesium.Camera.DEFAULT_VIEW_RECTANGLE = Cesium.Rectangle.fromDegrees(138.63, 35.47, 138.83, 35.27);
    Cesium.Camera.DEFAULT_VIEW_FACTOR = 0;

    const viewer = new Cesium.Viewer('cesiumContainer', {
      baseLayer: Cesium.ImageryLayer.fromProviderAsync(
        // 国土地理院提供の地理院タイル（オルソ画像）を利用する設定
        new Cesium.OpenStreetMapImageryProvider({
          url: 'https://cyberjapandata.gsi.go.jp/xyz/ort/',
          fileExtension: 'jpg',
          credit: new Cesium.Credit('<a href="https://maps.gsi.go.jp/development/ichiran.html">地理院タイル 電子国土基本図（オルソ画像）</a>', true)
        }),
      ),
      geocoder: false,  // 検索ボタン
      homeButton: true,  // ホームボタン
      baseLayerPicker: false,  // レイヤボタン
      requestRenderMode: true,  // https://cesium.com/blog/2018/01/24/cesium-scene-rendering-performance/#enabling-request-render-mode
      terrain: Cesium.Terrain.fromWorldTerrain(),  // 地形
    });

    function flyToMountFuji() {
      viewer.camera.flyTo({
        destination: Cesium.Cartesian3.fromDegrees(138.73, 34.87, 20000.0),
        orientation: {
          heading: 0.0,
          pitch: Cesium.Math.toRadians(-20.0),
          roll: 0.0,
        }
      });
    }
    flyToMountFuji();

    // Gamepad
    var loopRequired = false;

    function addgamepad(e) {
      // console.log('addgamepad', e.gamepad);
      if (!loopRequired) {
        requestAnimationFrame(updateStatus);
        loopRequired = true;
      }
    }

    function axisValue(val) {
      return Math.round(val * 1000);
    }

    function updateStatus() {
      loopRequired = false;
      for (const gamepad of navigator.getGamepads()) {
        if (!gamepad) continue;
        loopRequired = true;
        // for (const [i, button] of gamepad.buttons.entries()) {
        //   if (button.pressed) {
        //     console.log('button pressed', i, button);
        //   }
        // }
        // for (const [i, axis] of gamepad.axes.entries()) {
        //   var val = axisValue(axis)
        //   if (val != 0) {
        //     console.log('axis x 1000', i, val);
        //   }
        // }
        if (gamepad.buttons[2].pressed) {  // Xボタン
          var amount = 10.0;
          if (gamepad.buttons[7].pressed) {  // ZRボタン
            amount *= 20.0;
          }
          viewer.camera.zoomIn(amount);
        }
        if (gamepad.buttons[3].pressed) {  // Yボタン
          var amount = 10.0;
          if (gamepad.buttons[7].pressed) {  // ZRボタン
            amount *= 20.0;
          }
          viewer.camera.zoomOut(amount);
        }
        val = axisValue(gamepad.axes[0])  // Lスティック左右
        if (val != 0) {
          var angle = 1e-11 * Math.abs(val)*val;
          if (gamepad.buttons[6].pressed) {  // LRボタン
            angle *= 20.0;
          }
          viewer.camera.rotateRight(angle);
        }
        var val = axisValue(gamepad.axes[1])  // Lスティック上下
        if (val != 0) {
          var angle = 1e-11 * Math.abs(val)*val;
          if (gamepad.buttons[6].pressed) {  // LRボタン
            angle *= 20.0;
          }
          viewer.camera.rotateUp(angle);
        }
        var val = axisValue(gamepad.axes[2])  // Rスティック左右
        if (val != 0) {
          var angle = 1e-6 * val;
          viewer.camera.lookRight(angle);
        }
        var val = axisValue(gamepad.axes[3])  // Rスティック上下
        if (val != 0) {
          var angle = 1e-6 * val;
          if (gamepad.buttons[7].pressed) {  // ZRボタン
            angle *= 20.0;
          }
          viewer.camera.lookUp(angle);
        }
      }
      if (loopRequired) {
        requestAnimationFrame(updateStatus);
      }
    }

    window.addEventListener("gamepadconnected", addgamepad);
  </script>
  </div>
</body>

</html>
```
前半はCesium部分です。ブラウザでアクセスすると富士山が見えるように設定しました。
後半が Gamepad API部分です。[ゲームパッド API の使用](https://developer.mozilla.org/ja/docs/Web/API/Gamepad_API/Using_the_Gamepad_API) にあったように定期的にゲームコントローラの gamepad オブジェクトを取得し、ボタンやステックの値を見ているだけになります。ゲームコントローラが接続された時のイベント`gamepadconnected`をみて `updateStatus` を定期的に呼び出すかどうかを判断しています。[ゲームパッド API の使用](https://developer.mozilla.org/ja/docs/Web/API/Gamepad_API/Using_the_Gamepad_API) では ゲームコントローラが切断された時のイベント`gamepaddisconnected` もあったのですが使わなかったので入っていません。
ボタンやステックが操作されたときに、Cesium のカメラの位置を変更しています。動かす値とかは動かしてなんとなく決めた値です。この編はカメラの位置やZoomとかで変化させたほうがよいと思ったのですがそのあたりがわからず、動かして決めました。Lステックでは `viewer.camera.rotateXXX` を使っています。これは地球に沿って移動するというものです。

# おわり

Lステックで左右に倒すと横にスライドするように動きます。飛行機のように旋回して動くような感じにはならなかった。 `rotateXXX` がよくなかったのかも…