Three.jsでキーボードとNipplejsジョイスティックの入力を受け取る方法

3Dシーンが表示できたら、次はキャラクターを動かしたくなります。今回はキーボード(WASD)とスマホ用のバーチャルジョイスティックの両方でプレイヤーを動かす実装です。

ジョイスティックにはNipplejsという定番ライブラリを使います。名前はふざけてますが、マルチタッチ対応で挙動も安定していて、スマホ向けの3Dコンテンツではこれ一択だと思っています。

デモはPCならWASDか矢印キー(クリックしてフォーカスしてから)、スマホなら下のジョイスティックで箱を動かせます。

デモを別タブで開く

キーボード入力はkeyStatesパターンで

keydown イベントの中で直接プレイヤーを動かすと、キーリピートの間隔でしか動かずカクカクになります。そこで、イベントでは「押されているかどうか」だけを記録して、移動処理はアニメーションループ側でやるのが定番パターンです。

const keyStates = {};

document.addEventListener('keydown', (event) => {
  keyStates[event.code] = true;
});

document.addEventListener('keyup', (event) => {
  keyStates[event.code] = false;
});

たったこれだけですが、これで「今WとDが同時に押されている」という状態がいつでも参照できるようになります。斜め移動や複数キーの同時押しも自然に扱えます。

event.key ではなく event.code を使っているのには理由があります。code は物理的なキー位置(KeyW など)を返すので、日本語配列でも英語配列でも、CapsLockがONでも同じ値になります。キャラ操作には code が向いています。

ループ側で移動を処理する

const speed = 8; // 秒速8ユニット

function updatePlayer(deltaTime) {
  const move = new THREE.Vector3();

  if (keyStates['KeyW'] || keyStates['ArrowUp']) move.z -= 1;
  if (keyStates['KeyS'] || keyStates['ArrowDown']) move.z += 1;
  if (keyStates['KeyA'] || keyStates['ArrowLeft']) move.x -= 1;
  if (keyStates['KeyD'] || keyStates['ArrowRight']) move.x += 1;

  if (move.lengthSq() > 0) {
    move.normalize().multiplyScalar(speed * deltaTime);
    player.position.add(move);
    player.rotation.y = Math.atan2(move.x, move.z); // 進行方向を向く
  }
}

const clock = new THREE.Clock();
function animate() {
  const deltaTime = clock.getDelta();
  updatePlayer(deltaTime);

  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

ポイントは2つあります。

1つ目は normalize()。前と右を同時に押したとき、そのまま足すと斜め移動だけ√2倍(約1.4倍)速くなってしまいます。ベクトルを正規化してから速度を掛けることで、どの方向も同じ速さになります。

2つ目は deltaTime(前フレームからの経過秒数)を掛けること。これで「1フレームあたり」ではなく「1秒あたり」の移動量になり、60Hzでも144Hzのモニターでも同じ速度で動きます。

進行方向を向かせる Math.atan2(move.x, move.z) は、移動ベクトルからY軸回転の角度を出す定番の式です。この一行があるだけでキャラが生きて見えるので、ぜひ入れてください。

Nipplejsでバーチャルジョイスティック

NipplejsはESモジュールではない普通のスクリプトなので、importmapとは別に <script> タグで読み込みます。

<script src="https://cdnjs.cloudflare.com/ajax/libs/nipplejs/0.10.2/nipplejs.min.js"></script>

ジョイスティックを表示する位置は、CSSで配置した空のdivで指定します。

<div id="joystick"></div>

<style>
  #joystick {
    pointer-events: auto;
    position: absolute;
    bottom: 15%;
    left: 50%;
    width: 0;
    height: 0;
    touch-action: manipulation;
  }
</style>

そして生成と入力の受け取りです。

const joystickSize = 100;
const maxDistance = joystickSize / 2;
let joyRadian = 0;   // スティックを倒した角度
let joyDistance = 0; // スティックを倒した量(0〜1)

const manager = nipplejs.create({
  zone: document.getElementById('joystick'),
  size: joystickSize,
  color: 'white',
  mode: 'static',      // 固定位置に常時表示する
  restJoystick: true,  // 離したら中央に戻る
  shape: 'circle',
  dynamicPage: true,   // ページ構造が動的でも位置ズレしない
});

manager.on('move', (evt, data) => {
  joyRadian = data.angle.radian;
  joyDistance = Math.min(data.distance / maxDistance, 1);
});

manager.on('end', () => {
  joyDistance = 0;
});

考え方はキーボードと同じで、イベントでは角度と倒し具合を記録するだけにして、実際の移動はループ側でやります。

if (joyDistance > 0) {
  move.x += Math.cos(joyRadian) * joyDistance * speed * deltaTime;
  move.z += -Math.sin(joyRadian) * joyDistance * speed * deltaTime;
}

Nipplejsの角度は数学の単位円と同じ(右が0、上が90度)ですが、Three.jsのZ軸は手前がプラスなので、sin の符号を反転させて画面の上方向を奥(-Z)に対応させています。ここの座標変換を間違えると「上に倒したのに手前に来る」となるので、動きが変なときはまず符号を疑ってください。

data.distance / maxDistance で倒し具合を0〜1に正規化しているのもポイントです。これを速度に掛けることで、ちょっと倒せばゆっくり歩く、いっぱいに倒せば全速力、というアナログな操作になります。

モードの選び方とスマホでの注意点

mode: 'static' は決まった位置にジョイスティックが常時表示されるモードです。他に、タッチした場所にその都度出現する 'dynamic' もあります。ゲームっぽいUIなら static、画面を広く使いたいなら dynamic という使い分けです。

スマホで動かすときはビューポートの設定も大事です。

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

ジョイスティック操作中にピンチズームやダブルタップ拡大が発動すると操作になりません。user-scalable=no と、CSS側の touch-action の指定で防ぎます。

まとめ

  • 入力イベントでは状態の記録だけ、移動処理はループ側で(keyStatesパターン)
  • キーの判定は event.key ではなく event.code
  • 移動量は normalize()deltaTime で正規化
  • Nipplejsは角度(radian)と距離(distance)を保持してループで反映
  • カメラを追従させたい場合の実装はモデルインポートの回のあとに肉付けしていきます

Three.js解説シリーズ(全7回)

  1. 【Three.js入門】ビルドツール不要、CDNだけで始める基本の使い方
  2. レンダラー・ライト・カメラの使い方と設定まとめ
  3. キーボードとNipplejsでプレイヤーを動かす(この記事)
  4. GLTFLoaderで3Dモデルをインポートする
  5. ファイル分割はDIパターンがおすすめ
  6. CloudflareへのデプロイとWebSocketでの位置同期
  7. WebGLレンダラーとCSS3Dレンダラーを共存させる