[JavaScript] Three.jsで段差を登って降りられるキャラクター移動を実装する

はじめに

去年の12月16日からThree.jsで開発をスタートして、今日で17日目。

最初は、平面移動で四角いボックスを動かし壁に衝突判定を作る所から実装を開始してます。

最初に書いた記事は以下。

今日までの間、さまざまな実装し、その内容をほぼすべて記事を書いてきましたが、初期段階から先送りにしてた問題がありそれは

「平面しか移動できない」

という事。

今回は、ゲームボックスの実装が終わったのもあり、先送りにしてた段差の移動を実装してみました。

最初の設計

最初は THREE.Box3 だけで当たり判定を完結させる 方針だった。

  • プレイヤーに Box3 を持たせる
  • 移動先に仮の Box3(先読み Box)を作る
  • intersectsBox() で衝突を判定する

この方法は、

  • 壁に当たって止まる
  • オブジェクトをすり抜けない

という最低限の挙動は簡単に実現できる。

const predictedBox = new THREE.Box3().setFromCenterAndSize(
  predictedPosition,
  new THREE.Vector3(width, height, depth)
);

if (predictedBox.intersectsBox(wallBox)) {
  // 移動しない
}

この段階では、

  • 段差

といった 意味の違いを一切考えていなかった

Box3 は単に 「この空間に何かがあるかどうか」 しか分からないため、

  • それが壁なのか
  • 上に乗れる床なのか
  • 登れる段差なのか

という判断ができない。


Box3 だけでは足りなかった点

段差を実装しようとした瞬間、この設計が破綻した。

  • 登れる段差と、登れない壁を区別できない
  • 「前に当たった」という情報しか得られない
  • 当たった“理由”が分からない

結果として、

  • 登れるが降りられない
  • 壁を段差として誤認識する
  • Y 座標の調整が場当たり的になる

といった問題が発生した。

これはバグというより、 Box3 に意味を背負わせすぎた設計ミスだった。


この時点での結論

Box3 は 形状の判定には使えるが、 挙動の判断には使えない

  • 「当たったかどうか」
  • 「何に当たったか」

この 2 つを同時に扱うには、 Box3 だけでは情報が足りなかった。

そこで、 衝突判定そのものに“意味”を持たせる設計に切り替えることになる。

破綻ポイント

Box3 だけで衝突判定を行う設計は、段差を導入した時点で明確に破綻した。

登れるが降りられない

段差に対して、

  • 前方に Box3 が存在する
  • 衝突している

という事実だけを見て移動を止めていたため、

  • 段差の「側面」に当たる
  • それを壁と同じ扱いにしてしまう

という状態が発生した。

その結果、

  • 段差の上には登れる

  • しかし、同じ段差から降りようとすると

    • 側面の Box3 に衝突した判定になり
    • 移動が完全に止まる

という不自然な挙動になる。

ここでは、

  • 登る
  • 降りる

という 本来は別の処理である動作を、同じ衝突判定で扱っていた


当たり判定が“意味を持っていない”ことに気づく

この時点で得られていた衝突情報は、常にこれだけだった。

  • 「前方に Box3 がある」

それ以上でも、それ以下でもない。

つまり、

  • それが壁なのか
  • 床なのか
  • 段差なのか

という判断が、コード上では一切できなかった。

段差を登れるかどうか、という判断を Box3 のサイズ調整や EPS 値の微調整で解決しようとすると

  • 特定のケースだけが偶然うまく動く
  • 別の場所で破綻する

という状態を繰り返すことになる。

この時点で問題だったのは、

  • 実装の細かさではなく
  • 設計そのものに「意味の層」が存在しないこと

だった。


問題の本質

Box3 はあくまで「形状の衝突」を見るためのものだ。

  • 衝突した理由
  • その衝突にどう対応すべきか

という判断を行うには、

  • 何に当たったのか
  • それはどういう役割のオブジェクトなのか

という情報が必要になる。

ここで初めて、

衝突判定に“意味”を持たせる設計が必要だ という結論に至った。

設計の転換

破綻の原因は明確だった。 衝突している「形」しか見ておらず、「意味」を見ていなかった。

そこで、衝突判定の設計を根本から見直した。


collider を主語にする

Box3 単体で衝突判定を行うのではなく、 衝突対象そのものを 1 つのデータとして扱う設計に切り替えた。

{
  name: 'objectName',
  mesh: THREE.Object3D,
  box: THREE.Box3,
  type: 'wall'
}

この構造体を collider と定義し、

  • mesh(見た目)
  • box(衝突形状)
  • type(意味)

をまとめて管理するようにした。

これにより、

  • 「どの Box3 に当たったか」ではなく
  • 「どの collider に当たったか」

を返せるようになった。

衝突判定の関数も、

Box3  boolean

ではなく、

moveDirection  collider | null

を返す形に変更した。

if (predictedBox.intersectsBox(collider.box)) {
  return collider;
}

type(wall / ground / step)の導入

collider に type を持たせることで、 衝突後の挙動を明確に分離できるようになった。

  • wall

    • 完全に移動を止める対象
  • ground

    • 足元として扱う床
    • 横移動は阻害しない
  • step

    • 段差として扱うオブジェクト
    • 登れる可能性がある対象

これにより、

  • 「当たったかどうか」ではなく
  • 「当たった相手に対して何をするか」

type ごとに記述できるようになった。

switch (hit.type) {
  case 'wall':
    break;

  case 'ground':
    move();
    break;

  case 'step':
    tryStepUp();
    break;
}

設計変更の効果

この転換によって、

  • 段差と壁を誤認識しない
  • 降りる処理を壁判定から切り離せる
  • 判定ロジックが数値調整に依存しなくなる

という状態を作ることができた。

Box3 は引き続き使用するが、 意味の判断は collider が担う

この役割分離が、以降の実装を成立させる前提になった。

実装の整理

設計を collider 中心に切り替えたことで、 各処理の役割を明確に分離できるようになった。

ここでは、特に重要だった 3 つの処理を整理する。


checkPreCollision の役割

checkPreCollision は、 「移動してよいか」を判断する関数ではない

この関数の役割は一つだけ。

移動方向に、どの collider が存在するかを返す

export function checkPreCollision(moveDirection) {
  const predictedBox = createPredictedBox(moveDirection);

  for (const c of config.model.collider) {
    if (c.mesh === config.player.box) continue;

    if (predictedBox.intersectsBox(c.box)) {
      return c; // collider を返す
    }
  }

  return null;
}
  • 判定結果を boolean にしない
  • 移動の可否をここで決めない

この関数はあくまで 情報提供 に徹し、 「どう動くか」は呼び出し側に委ねる。

これにより、

  • 壁に当たった場合
  • 段差に当たった場合
  • 床に当たった場合

を、同じインターフェースで扱えるようになった。


footCollisionCheck の責務

footCollisionCheck は、 段差(step)に対してのみ使う補助処理として切り出した。

この関数が行うのは、

  • 前方かつ少し上から
  • 下向きに Ray を飛ばし
  • 「登れる面が存在するか」を調べる

という一点だけ。

export function footCollisionCheck(stepCollider, stepHeight, moveDirection) {
  if (!stepCollider || stepCollider.type !== 'step') return false;

  const forwardOffset = moveDirection.clone().normalize().multiplyScalar(0.2);
  const origin = config.player.box.position.clone().add(forwardOffset);
  origin.y += stepHeight;

  const ray = new THREE.Raycaster(
    origin,
    new THREE.Vector3(0, -1, 0),
    0,
    stepHeight + 0.5
  );

  const hits = ray.intersectObject(stepCollider.mesh, true);

  for (const hit of hits) {
    if (hit.face && hit.face.normal.y > 0.7) {
      const heightDiff = hit.point.y - config.player.box.position.y;
      if (heightDiff >= -0.01 && heightDiff <= stepHeight) {
        config.player.box.position.y = hit.point.y;
        return true;
      }
    }
  }

  return false;
}

ここでは、

  • 壁かどうか
  • 移動するかどうか

といった判断は一切行わない。

「登れるか?」だけを返す という責務に限定している。


snapToGround の責務

snapToGround は、 キャラクターが立てる床が存在するかを判定する処理として独立させた。

export function snapToGround(groundMeshes) {
  const ray = new THREE.Raycaster(
    config.player.box.position.clone().add(new THREE.Vector3(0, 0.1, 0)),
    new THREE.Vector3(0, -1, 0),
    0,
    2
  );

  const hits = ray.intersectObjects(groundMeshes, false);

  if (hits.length > 0) {
    config.player.box.position.y = hits[0].point.y;
  } else {
    config.player.box.position.y -= 0.1;
  }
}

この処理は、

  • 登った後
  • 移動した後
  • 降りるとき

など、Y 座標を確定させたい場面で呼び出す。

重要なのは、

  • 前方の衝突を一切見ない
  • 足元だけを見る

という点。


責務分離の効果

この整理によって、

  • 横方向の衝突判定
  • 段差を登れるかの判定
  • 地面に立てるかの判定

が完全に分離された。

それぞれが独立しているため、

  • 数値調整が局所化する
  • デバッグ時に原因を追いやすい
  • 機能追加が破綻しにくい

という構造になった。

以降の実装は、 この責務分離を前提に進められる。

了解。続ける。


完成形

最終的に到達した実装は、 見た目の挙動以上に 構造として安定した状態になった。


登れる

段差を「登れるかどうか」は、

  • 前方に衝突対象が存在する
  • その collider の typestep である
  • 上方向に余裕があり、足元に立てる面がある

という条件がそろったときにのみ成立する。

この判定は、

  • Box3 による先読み衝突
  • Raycaster による足元確認

を組み合わせることで実現している。

段差は壁と区別されているため、

  • 登れる段差
  • 登れない壁

を誤認識することはない。


降りられる

降りる動作は、 段差として特別扱いしないことで成立している。

  • 前方に step があっても
  • 登れない場合は移動を許可する
  • 移動後に snapToGround を実行する

これにより、

  • 下に床があれば自然に降りる
  • 床がなければ落下する

という挙動になる。

「降りる」という動作を 衝突判定で止めないことがポイントだった。


壊しても直せる

この実装の最大の成果は、 挙動そのものではなく 構造の明確さにある。

  • 衝突対象はすべて collider として管理される
  • 判定結果は collider が返される
  • 挙動は type ごとに分岐する

そのため、

  • 新しいオブジェクトを追加する
  • type を増やす
  • 判定方法を変更する

といった変更を行っても、 影響範囲を限定した修正が可能になった。


完成形の特徴

  • 数値調整に依存しない
  • 判定の意味がコード上で読める
  • 部分的に壊しても原因を追える

この状態に到達したことで、 段差処理は「一度作って終わり」ではなく、 拡張可能な基盤として機能するようになった。