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回)