Unityを触り始めた頃は、プレイヤーの移動・ジャンプ・当たり判定・カメラ制御・アニメーション切り替え……全部をひとつの Update() に押し込んでしまいがちですよね。動くことは動くけど、少し仕様を変えようとしただけでコードがぐちゃぐちゃになり、「どこを触ればいいのか分からない」状態になりがちです。

とくに「崖掴まり(Ledge Grab)」のようなアクションは、移動ロジックとごちゃ混ぜに書いてしまうと一気にカオスになります。

  • Raycast の条件
  • 崖に掴まる位置の補正
  • 掴まり中は入力を無効にする
  • 登り切った瞬間の位置調整

こういった処理をすべてプレイヤーの巨大スクリプトに押し込むと、デバッグも改造もつらくなってしまいます。

そこでこの記事では、「崖掴まり」だけを担当する 「LedgeGrab」コンポーネント を用意して、移動ロジックときれいに分離する方法を紹介します。プレイヤーには「移動コンポーネント」「ジャンプコンポーネント」「LedgeGrabコンポーネント」といった形で役割を分けていくイメージですね。

【Unity】崖でスッと止まってカチッと登る!「LedgeGrab」コンポーネント

ここでは、以下のようなシンプルな 3D 崖掴まりを実装します。

  • 前方に Raycast を飛ばして「掴める崖の角」を検出
  • ギリギリ届く距離なら「掴まり状態」に移行
  • 掴まり中は重力と通常移動を止める
  • ボタン入力(例:ジャンプ)で崖の上に体を引き上げる

プレイヤーの「通常移動」は別コンポーネントで実装しておき、この LedgeGrab は「Rigidbody を持ったキャラ」にアタッチして使う想定です。

フルコード(LedgeGrab.cs)


using UnityEngine;

/// <summary>
/// 崖掴まり処理を担当するコンポーネント
/// Rigidbody を使った 3D キャラクター向けの想定です。
/// </summary>
[RequireComponent(typeof(Rigidbody))]
public class LedgeGrab : MonoBehaviour
{
    // ====== インスペクタで調整するパラメータ群 ======

    [Header("Raycast 設定")]
    [SerializeField]
    private LayerMask ledgeLayerMask; // 掴まれる足場のレイヤー

    [SerializeField]
    [Tooltip("前方に飛ばす Ray の長さ(どれくらい先の崖を検出するか)")]
    private float forwardCheckDistance = 0.7f;

    [SerializeField]
    [Tooltip("胸の高さあたりから前方に Ray を飛ばすオフセット")]
    private Vector3 chestRayOffset = new Vector3(0f, 1.2f, 0f);

    [SerializeField]
    [Tooltip("足元あたりから前方に Ray を飛ばすオフセット")]
    private Vector3 feetRayOffset = new Vector3(0f, 0.3f, 0f);

    [Header("掴まり判定の条件")]
    [SerializeField]
    [Tooltip("掴まり可能な最大の高さ差(足元から崖の上までの高さ)")]
    private float maxLedgeHeight = 1.5f;

    [SerializeField]
    [Tooltip("掴まり可能な最小の高さ差(高すぎるとジャンプで届かない想定)")]
    private float minLedgeHeight = 0.5f;

    [SerializeField]
    [Tooltip("掴まり状態に入るとき、崖の面からどれくらい手前にオフセットするか")]
    private float ledgeForwardOffset = 0.3f;

    [SerializeField]
    [Tooltip("掴まる位置の Y をどれだけ上げるか(キャラの手の位置補正用)")]
    private float ledgeGrabHeightOffset = 0.0f;

    [Header("引き上げ設定")]
    [SerializeField]
    [Tooltip("崖の上に登り切ったときの、前方へのオフセット量")]
    private float climbForwardOffset = 0.5f;

    [SerializeField]
    [Tooltip("崖の上に登り切ったときの、上方向へのオフセット量")]
    private float climbUpOffset = 0.5f;

    [SerializeField]
    [Tooltip("掴まり中に押す「登る」ボタン(Input System の Action 名)")]
    private string climbButtonName = "Jump"; // 古い InputManager を使う場合の例

    [Header("デバッグ表示")]
    [SerializeField]
    private bool drawDebugGizmos = true;

    // ====== 内部状態 ======

    private Rigidbody rb;

    // 掴まり中かどうか
    private bool isGrabbing = false;

    // 掴まった位置情報
    private Vector3 grabbedLedgePoint;
    private Vector3 grabbedLedgeNormal;

    // 掴まり前の Rigidbody 設定を保存しておく
    private bool previousUseGravity;
    private RigidbodyConstraints previousConstraints;

    private void Awake()
    {
        rb = GetComponent<Rigidbody>();
    }

    private void Update()
    {
        // 掴まり中なら「登る」入力を監視
        if (isGrabbing)
        {
            HandleClimbInput();
            return;
        }

        // 掴まり中でないときだけ、新規に崖を探す
        TryDetectLedge();
    }

    /// <summary>
    /// 掴まり中の登り入力を処理
    /// </summary>
    private void HandleClimbInput()
    {
        // ここでは Input.GetButtonDown を使用(古い InputManager)
        // 新しい Input System を使う場合は、別コンポーネントからメソッドを呼ぶ形にするとよいです。
        if (Input.GetButtonDown(climbButtonName))
        {
            ClimbUp();
        }
    }

    /// <summary>
    /// 崖が目の前にあるかをチェックし、条件を満たせば掴まり状態に入る
    /// </summary>
    private void TryDetectLedge()
    {
        // キャラクターの前方方向
        Vector3 forward = transform.forward;

        // 胸の位置から前方に Raycast
        Vector3 chestOrigin = transform.position + chestRayOffset;
        // 足元の位置から前方に Raycast
        Vector3 feetOrigin = transform.position + feetRayOffset;

        // 胸の位置の Ray がヒットしたら「崖の面」を取得
        if (Physics.Raycast(chestOrigin, forward, out RaycastHit chestHit, forwardCheckDistance, ledgeLayerMask, QueryTriggerInteraction.Ignore))
        {
            // 足元からも Ray を飛ばし、「足元はまだ空中である」ことを確認
            bool feetHit = Physics.Raycast(feetOrigin, forward, out RaycastHit feetHitInfo, forwardCheckDistance, ledgeLayerMask, QueryTriggerInteraction.Ignore);

            if (!feetHit)
            {
                // 崖の上の位置(表面から上方向に最大高さ分を探索)
                if (FindLedgeTop(chestHit, out Vector3 ledgeTopPoint))
                {
                    float heightDiff = ledgeTopPoint.y - transform.position.y;

                    // 指定した高さ範囲内なら掴める
                    if (heightDiff >= minLedgeHeight && heightDiff <= maxLedgeHeight)
                    {
                        StartGrab(chestHit, ledgeTopPoint);
                    }
                }
            }
        }
    }

    /// <summary>
    /// 崖の上の「平らな面」の位置を探す
    /// </summary>
    /// <param name="chestHit">胸の Raycast のヒット情報(崖の側面)</param>
    /// <param name="ledgeTopPoint">見つかった崖上のポイント</param>
    /// <returns>見つかったかどうか</returns>
    private bool FindLedgeTop(RaycastHit chestHit, out Vector3 ledgeTopPoint)
    {
        // 崖の側面のヒットポイントから、少し上に向かって Ray を飛ばして上面を探す
        Vector3 start = chestHit.point + Vector3.up * maxLedgeHeight;
        Vector3 direction = Vector3.down;

        // 上から下に向かって Ray を飛ばし、足場の上面を見つける
        if (Physics.Raycast(start, direction, out RaycastHit topHit, maxLedgeHeight * 2f, ledgeLayerMask, QueryTriggerInteraction.Ignore))
        {
            ledgeTopPoint = topHit.point;
            return true;
        }

        ledgeTopPoint = Vector3.zero;
        return false;
    }

    /// <summary>
    /// 掴まり状態に入る
    /// </summary>
    private void StartGrab(RaycastHit chestHit, Vector3 ledgeTopPoint)
    {
        isGrabbing = true;

        // 崖の法線(外向き)
        grabbedLedgeNormal = chestHit.normal;

        // キャラクターを崖の方に向ける
        Vector3 lookDir = -grabbedLedgeNormal;
        lookDir.y = 0f;
        if (lookDir.sqrMagnitude > 0.0001f)
        {
            transform.rotation = Quaternion.LookRotation(lookDir, Vector3.up);
        }

        // 掴まり位置を計算
        // 崖の上のポイントから少し手前 & 高さ補正
        Vector3 ledgeForward = -grabbedLedgeNormal; // 崖の上から見て「手前」方向
        Vector3 grabPos = ledgeTopPoint
                          + ledgeForward * ledgeForwardOffset
                          + Vector3.up * ledgeGrabHeightOffset;

        grabbedLedgePoint = grabPos;

        // Rigidbody の状態を保存して、掴まり用に変更
        previousUseGravity = rb.useGravity;
        previousConstraints = rb.constraints;

        rb.useGravity = false;
        rb.velocity = Vector3.zero;
        rb.angularVelocity = Vector3.zero;

        // 掴まり中は位置を固定(回転は Y のみ許可するなど、好みに応じて)
        rb.constraints = RigidbodyConstraints.FreezePositionX
                         | RigidbodyConstraints.FreezePositionY
                         | RigidbodyConstraints.FreezePositionZ
                         | RigidbodyConstraints.FreezeRotationX
                         | RigidbodyConstraints.FreezeRotationZ;

        // 物理挙動ではなく、Transform を直接動かして掴まり位置にスナップ
        transform.position = grabbedLedgePoint;
    }

    /// <summary>
    /// 崖の上に登る
    /// </summary>
    private void ClimbUp()
    {
        // 崖の上に移動する位置を計算
        Vector3 climbForward = -grabbedLedgeNormal; // 崖の上から見て前方方向
        Vector3 targetPos = grabbedLedgePoint
                            + climbForward * climbForwardOffset
                            + Vector3.up * climbUpOffset;

        // ここでは一瞬でワープさせる(アニメーションに合わせて補間したい場合は別途処理)
        transform.position = targetPos;

        EndGrab();
    }

    /// <summary>
    /// 掴まり状態を終了し、Rigidbody の設定を元に戻す
    /// </summary>
    private void EndGrab()
    {
        isGrabbing = false;

        rb.useGravity = previousUseGravity;
        rb.constraints = previousConstraints;
    }

    /// <summary>
    /// 外部から「強制的に掴まり状態を解除したい」ときに呼べるメソッド
    /// 例: ダメージを受けたときなど。
    /// </summary>
    public void ForceRelease()
    {
        if (!isGrabbing) return;
        EndGrab();
    }

    private void OnDrawGizmosSelected()
    {
        if (!drawDebugGizmos) return;

        // Scene ビューで Ray の位置を確認できるように Gizmo を描画
        Gizmos.color = Color.yellow;

        Vector3 chestOrigin = transform.position + chestRayOffset;
        Vector3 feetOrigin = transform.position + feetRayOffset;

        Vector3 forward = transform.forward;

        Gizmos.DrawLine(chestOrigin, chestOrigin + forward * forwardCheckDistance);
        Gizmos.DrawLine(feetOrigin, feetOrigin + forward * forwardCheckDistance);

        // 掴まり位置の確認
        if (isGrabbing)
        {
            Gizmos.color = Color.cyan;
            Gizmos.DrawSphere(grabbedLedgePoint, 0.05f);
        }
    }
}

使い方の手順

ここでは「Rigidbody で動く 3D プレイヤー」に崖掴まりを付ける例で説明します。

  1. プレイヤーの準備
    • キャラクターの GameObject に Rigidbody を追加(すでにある場合はそのまま)
    • 移動・ジャンプ用のスクリプトは別コンポーネントとして用意しておきましょう(例:CharacterMover
    • Rigidbody の Constraints は「Y 回転以外を固定」など、普段の挙動に合わせて設定しておきます
  2. LedgeGrab コンポーネントを追加
    • 上記コードを LedgeGrab.cs として保存
    • プレイヤーの GameObject にアタッチ
    • インスペクタで以下を設定
      • Ledge Layer Mask: 掴まり可能な足場に設定しているレイヤーを指定(例:Environment
      • Forward Check Distance: 0.5〜0.8 くらいから調整
      • Chest / Feet Ray Offset: モデルの高さに合わせて微調整
      • Min / Max Ledge Height: 掴まりたい高さ範囲に合わせて値を決める
  3. 掴まれる足場(崖)の設定
    • 床や足場の MeshCollider / BoxCollider を持つオブジェクトを用意
    • 掴まりたいオブジェクトの Layer を、LedgeGrabLedge Layer Mask で指定したレイヤーに変更
    • 崖の「角」がはっきり出るように、コライダーの形状を調整(極端に丸い形状だと判定が不安定になります)
  4. 動作の確認と調整
    • プレイヤーを崖の手前でジャンプさせ、ギリギリ届かない高さの位置にキャラを飛ばしてみる
    • 前方に向いた状態で崖に接近すると、崖の角でキャラがピタッと止まり、掴まり状態になるはずです
    • 掴まり中に Jump(スペースキーなど)を押すと、崖の上にスッとワープします
    • ワープではなくアニメーションに合わせて滑らかに登らせたい場合は、後述の「改造案」を参考にしてみてください

同じコンポーネントを「敵キャラ」にアタッチすれば、敵も崖を掴まるようにできますし、「動く床」につければ、プレイヤーが動く足場の角で掴まり待機するギミックも作れます。振る舞いをコンポーネントに分けておくと、こうした再利用がとても楽になります。

メリットと応用

LedgeGrab を独立したコンポーネントとして切り出すことで、次のようなメリットがあります。

  • プレイヤーの移動スクリプトがシンプルになる
    移動ロジックから「崖掴まり」の条件分岐や Raycast の処理を追い出せるので、CharacterMover などのクラスは「走る・歩く・ジャンプ」だけに集中できます。
  • プレハブ単位で機能を ON/OFF できる
    同じ移動スクリプトを使っていても、LedgeGrab コンポーネントを付けるかどうかで「崖掴まりできるキャラ」「できないキャラ」を簡単に作り分けできます。
  • レベルデザインがやりやすくなる
    掴まり可能な足場は特定レイヤーにしておけば、ステージデザイナーは「この足場は掴まり OK」「こっちは NG」とレイヤーを切り替えるだけで制御できます。スクリプト側はレイヤーマスクを見るだけなので、実装もシンプルです。
  • アニメーションやエフェクトとの連携がしやすい
    掴まり開始・登り完了のタイミングは StartGrab / ClimbUp で一元管理されているので、ここでアニメーション再生やエフェクト再生を呼び出すだけで、演出を追加できます。

さらに、少し改造するだけでかなり表現の幅が広がります。

  • 登るときに補間して動かす(アニメーションと同期)
  • 一定時間掴まり続けると自動で落ちる
  • スタミナ制と連携して、スタミナが尽きたら掴まれないようにする

例えば、「登り動作を 0.3 秒かけて補間する」簡易な改造案はこんな感じです。


private float climbDuration = 0.3f; // 登りにかける時間

private void ClimbUpSmooth()
{
    // 崖の上の目標位置を計算
    Vector3 climbForward = -grabbedLedgeNormal;
    Vector3 targetPos = grabbedLedgePoint
                        + climbForward * climbForwardOffset
                        + Vector3.up * climbUpOffset;

    // コルーチンで補間移動
    StartCoroutine(ClimbCoroutine(targetPos));
}

private System.Collections.IEnumerator ClimbCoroutine(Vector3 targetPos)
{
    Vector3 startPos = transform.position;
    float elapsed = 0f;

    while (elapsed < climbDuration)
    {
        elapsed += Time.deltaTime;
        float t = Mathf.Clamp01(elapsed / climbDuration);

        // 緩急をつけたい場合は SmoothStep などを使う
        float eased = Mathf.SmoothStep(0f, 1f, t);
        transform.position = Vector3.Lerp(startPos, targetPos, eased);

        yield return null;
    }

    transform.position = targetPos;
    EndGrab();
}

HandleClimbInput() から ClimbUp() の代わりに ClimbUpSmooth() を呼べば、アニメーションと合わせて「よじ登る」表現に発展させられます。

崖掴まりは一見複雑に見えますが、「Ray で崖を見つける」「掴まり状態に入る」「登る」 という小さな責務に分解してコンポーネント化しておくと、あとからの改造がとても楽になります。ぜひ自分のプロジェクト用にパラメータや処理をカスタマイズしてみてください。