さて、以前の開発記でも少し触れた通り、3Dパズルゲームをスマートフォン対応させる際に最も頭を悩ませたのが「1本の指によるドラッグ操作で『カメラの視点回転』と『ブロックの重力スライド』をどうやって誤爆なしに使い分けるか」というUI/UXの課題でした。

1手のミスでコーナーの最大タイルが剥がれてしまう2048において、「盤面の裏側を見ようとしてカメラを回したつもりが、ブロックが意図しない方向へスライドしてしまった」という操作ミスは絶対に防がなければなりません。そこで今回は、js/game-renderer.jsとjs/main.jsに実装した「ハイブリッド・タッチ判定(Raycaster × 24pxエッジ安全マージン)」と、どんな縦長スマホ画面でも3Dキューブが絶対に見切れない「外接球FOV自動フレーミング計算」の数式を解説します。

01. タッチ開始座標による「キューブ上=スライド / 背景=カメラ回転」の自動分岐

まず基本となる操作ルールとして、2048×3Dでは「タッチ(またはマウス押下)を開始した地点が3Dキューブの枠内であればブロックスライド、外側の宇宙背景であればカメラの視点回転」として入力モードをロックする方式を採用しました。この判定を担っているのがjs/game-renderer.jsのisPointerOnCube(clientX, clientY)メソッドです。

// js/game-renderer.js : THREE.Raycaster と THREE.Box3 によるキューブ交差判定
const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1;
const ndcY = -((clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), this.camera);

const half = (this.size * this.cellSpan) / 2 * 1.08;
const box = new THREE.Box3(
  new THREE.Vector3(-half, -half, -half),
  new THREE.Vector3(half, half, half)
);
return this.raycaster.ray.intersectsBox(box);

ここで工夫しているのが、個々のタイルメッシュ(this.tileGroup.children)に対してレイキャストを行うのではなく、キューブ全体を包む仮想的なバウンディングボックスTHREE.Box3(実寸の1.08倍)に対して交差判定を行っている点です。もし個別のタイルメッシュに対してレイキャストしてしまうと、空きマスの隙間をタップしたときに「そこにはタイルがないから背景(=カメラ回転)だ」と判定されてしまい、盤面中央をスワイプしたのに視点がぐるっと回ってしまうからです。

02. スマホ特有の誤爆を防ぐ「画面外周24pxのカメラ回転セーフゾーン」

ところが、上記のBox3交差判定だけをスマートフォン実機でテストしてみると、まだ1つ問題が残っていました。スマートフォンの縦持ち画面では3Dキューブが画面横幅いっぱいに大きく表示されるため、「カメラを回そうとして画面の端を触ったつもりが、わずかにキューブの角(1.08倍ボックス)にかすってしまいスライド扱いになる」というケースがあったのです。

そこで追加したのが、モバイル幅(rect.width <= 768)のときに作動する「画面外周24pxのカメラ回転セーフゾーン」です。

// js/game-renderer.js : モバイル画面端24pxは強制的に「背景(カメラ回転)」として扱うガード
const localX = clientX - rect.left;
const localY = clientY - rect.top;
const edgeMargin = rect.width <= 768 ? 24 : 0;
if (edgeMargin > 0) {
  if (localX < edgeMargin || localX > rect.width - edgeMargin ||
      localY < edgeMargin || localY < rect.height - edgeMargin) {
    return false; // 必ずカメラ回転モードにする
  }
}

キャンバスの上下左右の端から24px以内で始まったタッチ操作は、たとえレイキャスト上はキューブの角と重なっていても無条件でfalse(カメラ回転モード)を返します。これにより、「画面の端をなぞれば100%確実にカメラが回る」「画面の中央付近を払えば確実にブロックがスライドする」という直感的な操作分離が完成したわけです。

03. 指を離す前に移動方向がわかる「スワイプ予測ガイド矢印」とハプティクス振動

さらに「意図しない方向へのスライド誤爆」をゼロにするため、タッチスライド中には指を画面から離す(確定する)前の段階で、今離したらどの軸方向へスライドするかを示す予測ガイドを表示するようにしました。

  • ドラッグ中のリアルタイム予測フェーズ(onSwipeProgress):キューブ上で指を動かしている最中、現在のカメラ角度で補正された移動方向(例:右 (X+)や上 (Y+))がキャンバス下部のHUDバッジと3D空間内の発光矢印(showDirectionGuide)にリアルタイム表示されます。もし「斜めに指がずれて違う方向になりそうだ」と気づいたら、指を元の位置へ戻してから離せばスライドをキャンセルできます。
  • 確定時のハプティクス触覚フィードバック:Android端末などnavigator.vibrateに対応している環境では、ブロックがマージした瞬間に12ms、HARDモードでGLITCHを粉砕した瞬間に[18, 25, 30]msの微細な振動フィードバックを返し、指先からも手応えを感じられるようにしています。

04. 縦長画面でキューブの角が切れない「外接球半径 √3/2 と水平視野角(Horizontal FOV)」の数式

最後に、3Dレンダラーのカメラ距離計算(radiusBoundsForSize())について解説します。Three.jsのPerspectiveCameraはデフォルトで垂直方向の視野角(Vertical FOV = 48度)を基準に投影行列を作ります。そのため、PCの横長モニター(アスペクト比 16:9)に合わせてカメラ距離を固定してしまうと、スマートフォンの縦長画面(アスペクト比 9:16 など、aspect < 1.0)で表示したときに水平方向の視野角(Horizontal FOV)が極端に狭くなり、回転させた3Dキューブの左右の角が画面外にはみ出して切れてしまうのです。

これを防ぐには、「1辺の長さ fs の立方体をどの角度に回転させても絶対に画面内に収まる最小カメラ距離 fitRadius」を幾何学的に求める必要があります。js/game-renderer.jsの実装数式を見てみましょう。

// js/game-renderer.js : 外接球と水平視野角から逆算するカメラ距離オートフィット
radiusBoundsForSize(size = this.size) {
  const fs = this.frameSizeFor(size); // 1辺の実寸 fs = size * cellSpan + 0.34
  const vFovRad = (this.camera ? this.camera.fov : 48) * Math.PI / 180;
  const aspect = (this.camera && this.camera.aspect) ? this.camera.aspect : 1;
  const halfVerticalFov = vFovRad / 2;
  const halfHorizontalFov = Math.atan(Math.tan(halfVerticalFov) * aspect);
  const minHalfFov = Math.min(halfVerticalFov, halfHorizontalFov);

  // 立方体の外接球半径 r = fs * √3 / 2 が視野角内に収まる距離 R = r / sin(minHalfFov)
  const fitRadius = (fs * Math.sqrt(3) / 2) / Math.sin(Math.max(0.15, minHalfFov)) * 1.05;
  // ...
}

数式の意味を順番に辿ると非常に明快です。

  1. 1辺の長さ fs の立方体が3次元空間でどのように回転しても、その頂点が描く最大軌跡は半径 r = fs * √3 / 2 の外接球(Bounding Sphere)の中に収まります。
  2. 縦長画面(aspect < 1)における水平半視野角 halfHorizontalFov は、垂直半視野角から Math.atan(Math.tan(halfVerticalFov) * aspect) で求まります。
  3. 半径 r の球が最小半視野角 minHalfFov の視錐台に内接するカメラ距離は、三角関数より r / Math.sin(minHalfFov) となります。これに5%の余白(* 1.05)を掛けることで、どんな画面比率のスマートフォンでも、3×3×3でも4×4×4でも、キューブを斜め45度に傾けたときの頂点までピッタリ美しく画面内に収まるわけです。

次回(第9回)は、クライアントサイドからバックエンドへ視点を移し、「Cloudflare Pages Functions + D1(SQLite)によるDENSE_RANK世界ランキングAPIの設計と、PWA Service Workerのオフラインキャッシュ戦略」について解説します。それでは、また次回の記事で!

Hybrid Touch & Auto Framing

スマホでもPCでも快適なハイブリッド操作を体験する

キューブ上スワイプで重力移動、外周ドラッグで視点回転。D-Padと組み合わせて自在に3D盤面を攻略しよう。

🎮 2048×3D をプレイする ➔ 🎓 操作チュートリアルを開く