Unityで2D / 3Dのジャンプ挙動を作っていると、プレイヤーが天井の「角」に頭をぶつけて、進行方向に引っかかってしまうことがありますよね。
多くの初心者実装では、このあたりの判定&補正をすべて Update に書き込んでしまい、ジャンプ処理・移動処理・当たり判定・補正処理がぐちゃっと混ざった「なんでも屋スクリプト」になりがちです。

そうなると、

  • バグが出たときにどの処理が悪いのか追いづらい
  • 別のキャラクター(敵など)にも同じ挙動を使いたいときにコピペ地獄になる
  • ちょっとした仕様変更(補正量を変えるなど)にすら大きな修正が必要

という、メンテナンス性の悪い状態になってしまいます。

そこでこの記事では、「ジャンプで天井の角に頭をぶつけたときだけ、プレイヤーを少し横にずらして通り抜けやすくする」という一点に責務を絞ったコンポーネント 「CornerCorrection」 を用意します。
プレイヤーの移動やジャンプ本体は別コンポーネントに任せて、角補正だけをこのコンポーネントに切り出すことで、よりクリーンな構成にしていきましょう。

【Unity】天井の角に引っかからないジャンプを作る!「CornerCorrection」コンポーネント

ここでは「横方向に動くキャラクターが、上方向に移動中(ジャンプ中など)に天井の角にぶつかったら、可能なら少しだけ横にスライドさせて通り抜けさせる」というロジックを実装します。
2Dでも3Dでも考え方は近いですが、この記事では汎用的に使えるよう3D版(CharacterControllerベース)で実装します。2Dの場合は Physics2D / Rigidbody2D に読み替えてもらえればOKです。


フルコード:CornerCorrection.cs


using UnityEngine;

/// <summary>
/// 天井の「角」に頭をぶつけたとき、横に少しスライドさせて
/// 引っかかりを軽減するコンポーネント。
///
/// ・CharacterController を前提とした3D用実装
/// ・上方向に移動中(ジャンプ中など)に天井に接触したら、
///   現在の移動方向に対して左右どちらかへ小さくレイを飛ばし、
///   空いている側へオフセット移動を試みる。
///
/// 責務は「角補正」のみ。移動やジャンプ処理は別コンポーネントに任せる想定。
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class CornerCorrection : MonoBehaviour
{
    // === 設定項目 ===

    [Header("補正設定")]

    [SerializeField]
    [Tooltip("角からどれくらい横にずらすか(メートル)。")]
    private float horizontalOffset = 0.25f;

    [SerializeField]
    [Tooltip("補正を試みる最大距離。ここに障害物があると補正しない。")]
    private float checkDistance = 0.3f;

    [SerializeField]
    [Tooltip("天井判定に使うレイヤー。プレイヤー自身のレイヤーは含めないこと。")]
    private LayerMask ceilingLayerMask = ~0; // デフォルトはすべて

    [SerializeField]
    [Tooltip("横方向の空きスペース判定に使うレイヤー。壁や他のコライダー用。")]
    private LayerMask obstacleLayerMask = ~0;

    [Header("動きの情報(外部移動コンポーネントから設定)")]

    [SerializeField]
    [Tooltip("現在のワールド速度(移動コンポーネントから毎フレーム渡してもらう)。")]
    private Vector3 currentVelocity = Vector3.zero;

    [SerializeField]
    [Tooltip("上方向にどれくらい動いているときに「頭をぶつけた」とみなすか。")]
    private float upwardVelocityThreshold = 0.1f;

    [SerializeField]
    [Tooltip("天井と判定するための上方向レイの長さ。")]
    private float ceilingCheckDistance = 0.2f;

    [SerializeField]
    [Tooltip("デバッグ表示を行うかどうか。")]
    private bool drawDebugGizmos = true;

    // === 内部参照 ===

    private CharacterController _controller;
    private Vector3 _lastPosition;

    private void Awake()
    {
        _controller = GetComponent<CharacterController>();
        _lastPosition = transform.position;
    }

    private void Update()
    {
        // ここでは「角補正の判定と実行」だけを行う
        TryCornerCorrection();
        _lastPosition = transform.position;
    }

    /// <summary>
    /// 外部の移動コンポーネントから現在の速度を渡してもらうためのAPI。
    /// 例: PlayerMover などから毎フレーム呼び出す。
    /// </summary>
    /// <param name="velocity">ワールド座標系での現在速度</param>
    public void SetCurrentVelocity(Vector3 velocity)
    {
        currentVelocity = velocity;
    }

    /// <summary>
    /// 角補正のメインロジック。
    /// 「上方向に動いている && 天井に接触している」場合にだけ動作。
    /// </summary>
    private void TryCornerCorrection()
    {
        // 上方向にほとんど動いていないなら何もしない
        if (currentVelocity.y <= upwardVelocityThreshold)
        {
            return;
        }

        // すでに天井で頭を押さえつけられている状態かをレイキャストで確認
        if (!IsHeadHittingCeiling(out RaycastHit ceilingHit))
        {
            return;
        }

        // 水平方向の移動ベクトルを取得
        Vector3 horizontalVelocity = new Vector3(currentVelocity.x, 0f, currentVelocity.z);
        if (horizontalVelocity.sqrMagnitude < 0.0001f)
        {
            // 横方向にほとんど動いていない場合は角補正の必要性が低いのでスキップ
            return;
        }

        // 進行方向に対して左右どちらにスライドできるかを判定
        Vector3 slideDirection = GetAvailableSlideDirection(horizontalVelocity.normalized, ceilingHit.point);

        // スライドできる方向がなければ何もしない
        if (slideDirection == Vector3.zero)
        {
            return;
        }

        // 実際に位置を補正してみる
        Vector3 offset = slideDirection * horizontalOffset;
        Vector3 targetPosition = transform.position + offset;

        // CharacterController 経由で動かすことで、他のコリジョンと整合性を保つ
        Vector3 moveDelta = targetPosition - transform.position;

        // ほんの少しの移動なので、Time.deltaTime を掛けずにそのまま Move
        _controller.Move(moveDelta);

        if (drawDebugGizmos)
        {
            Debug.DrawLine(transform.position, transform.position + offset, Color.cyan, 0.2f);
        }
    }

    /// <summary>
    /// プレイヤーの「頭」から上方向にレイを飛ばし、天井に当たっているかどうかを判定。
    /// </summary>
    private bool IsHeadHittingCeiling(out RaycastHit hit)
    {
        // CharacterController の中心と高さから「頭の位置」を計算
        Vector3 centerWorld = transform.TransformPoint(_controller.center);
        float headHeight = _controller.height * 0.5f;
        Vector3 headPosition = centerWorld + Vector3.up * (headHeight - _controller.radius * 0.5f);

        bool isHit = Physics.SphereCast(
            headPosition,
            _controller.radius * 0.9f, // 少し小さめの半径で判定
            Vector3.up,
            out hit,
            ceilingCheckDistance,
            ceilingLayerMask,
            QueryTriggerInteraction.Ignore
        );

        if (drawDebugGizmos)
        {
            Color c = isHit ? Color.red : Color.green;
            Debug.DrawRay(headPosition, Vector3.up * ceilingCheckDistance, c);
        }

        return isHit;
    }

    /// <summary>
    /// 現在の進行方向に対して「左」「右」どちらへスライドできるかを判定し、
    /// 空いている側の正規化ベクトルを返す。
    /// 空いていなければ Vector3.zero を返す。
    /// </summary>
    private Vector3 GetAvailableSlideDirection(Vector3 moveDir, Vector3 ceilingHitPoint)
    {
        // ワールド上の「右」ベクトルは、進行方向と上方向の外積で求める
        Vector3 worldUp = Vector3.up;
        Vector3 right = Vector3.Cross(worldUp, moveDir).normalized;

        // 左右どちらから先に試すかは「どちらのほうがプレイヤーにとって自然か」で決めてもよいが、
        // ここでは単純に「右 → 左」の順で試す。
        Vector3[] candidates = new Vector3[2] { right, -right };

        foreach (var dir in candidates)
        {
            if (CanSlideToSide(dir))
            {
                return dir;
            }
        }

        return Vector3.zero;
    }

    /// <summary>
    /// 指定方向に horizontalOffset 分だけスライドできるかどうかを判定。
    /// </summary>
    private bool CanSlideToSide(Vector3 sideDir)
    {
        // スライド先の中心位置を予測
        Vector3 predictedCenter = transform.position + sideDir * horizontalOffset;

        // CharacterController と同じ大きさのカプセルで Overlap チェックを行う
        float radius = _controller.radius;
        float height = _controller.height;

        // カプセルの上下端を計算
        Vector3 center = predictedCenter + _controller.center;
        Vector3 bottom = center + Vector3.down * (height * 0.5f - radius);
        Vector3 top = center + Vector3.up * (height * 0.5f - radius);

        Collider[] overlaps = Physics.OverlapCapsule(
            bottom,
            top,
            radius * 0.95f,
            obstacleLayerMask,
            QueryTriggerInteraction.Ignore
        );

        if (drawDebugGizmos)
        {
            Color col = overlaps.Length == 0 ? Color.blue : Color.yellow;
            Debug.DrawLine(bottom, top, col, 0.1f);
        }

        // 何かにめり込むならスライド不可
        return overlaps.Length == 0;
    }

#if UNITY_EDITOR
    private void OnDrawGizmosSelected()
    {
        if (!_controller) _controller = GetComponent<CharacterController>();

        Gizmos.color = Color.cyan;
        Vector3 center = transform.position + _controller.center;
        Gizmos.DrawWireSphere(center + Vector3.up * (_controller.height * 0.5f), 0.05f);
    }
#endif
}

使い方の手順

ここでは、典型的な「3Dアクションゲームのプレイヤー」にこのコンポーネントを組み込む例で説明します。

  1. プレイヤーに CharacterController を付ける
    プレイヤー用の GameObject(例: Player)を用意し、
    Component > Character > Character Controller を追加します。
    カプセルの高さや半径は、モデルの大きさに合わせて調整しておきましょう。
  2. CornerCorrection コンポーネントを追加
    同じ Player オブジェクトに、上記の CornerCorrection.cs をアタッチします。
    インスペクター上で以下を設定します。
    • Horizontal Offset: 0.2 ~ 0.4 くらいから試す
    • Check Distance: 0.2 ~ 0.3 くらい
    • Ceiling Layer Mask: 天井に使っているレイヤーを指定
    • Obstacle Layer Mask: 壁・床など、衝突して欲しいレイヤーを指定
  3. 移動コンポーネントから速度を渡す
    すでにプレイヤー移動用のスクリプト(例: PlayerMover)を使っている前提で、
    そのスクリプトから CornerCorrection に「現在の速度」を渡します。
    例として、非常にシンプルな移動スクリプトを示します。
    
    using UnityEngine;
    
    /// <summary>単純なプレイヤー移動+ジャンプ例(説明用)</summary>
    [RequireComponent(typeof(CharacterController))]
    public class SimplePlayerMover : MonoBehaviour
    {
        [SerializeField] private float moveSpeed = 5f;
        [SerializeField] private float jumpPower = 6f;
        [SerializeField] private float gravity = -9.81f;
    
        private CharacterController _controller;
        private CornerCorrection _cornerCorrection;
    
        private Vector3 _velocity;
    
        private void Awake()
        {
            _controller = GetComponent<CharacterController>();
            _cornerCorrection = GetComponent<CornerCorrection>();
        }
    
        private void Update()
        {
            // --- 横移動 ---
            float h = Input.GetAxisRaw("Horizontal");
            float v = Input.GetAxisRaw("Vertical");
    
            Vector3 inputDir = new Vector3(h, 0f, v).normalized;
            Vector3 move = inputDir * moveSpeed;
    
            // --- 地上判定&ジャンプ ---
            if (_controller.isGrounded)
            {
                _velocity.y = -1f; // 地面に押し付ける程度の小さな値
    
                if (Input.GetButtonDown("Jump"))
                {
                    _velocity.y = jumpPower;
                }
            }
            else
            {
                _velocity.y += gravity * Time.deltaTime;
            }
    
            // 最終的な速度ベクトル
            Vector3 finalVelocity = new Vector3(move.x, _velocity.y, move.z);
    
            // CharacterControllerで移動
            _controller.Move(finalVelocity * Time.deltaTime);
    
            // CornerCorrection に現在の速度を伝える
            if (_cornerCorrection != null)
            {
                _cornerCorrection.SetCurrentVelocity(finalVelocity);
            }
        }
    }
    

    このように、移動ロジックは SimplePlayerMover に閉じ込めておき、
    「速度情報だけを CornerCorrection に渡す」形にすると、責務分離がきれいになります。

  4. 天井に角を持つジオメトリを用意してテスト
    シーンに「L字型の天井」や「段差のある梁」など、角ができるようなコライダーを配置します。
    プレイヤーをその下からジャンプさせて、
    • 補正なしだと角に引っかかって前に進めない
    • 補正ありだと、少し横にずれてスムーズに通過できる

    という差が出るか確認してみましょう。
    うまくいかない場合は、Horizontal OffsetCeiling Check Distance を少しずつ調整してみてください。


メリットと応用

CornerCorrection をコンポーネントとして分離しておくと、次のようなメリットがあります。

  • プレイヤー移動コードがシンプルになる
    ジャンプや移動のロジックは「地面との接地」「入力の反映」「重力」などに集中させ、
    「天井角での引っかかり対策」という特殊な処理は別クラスに切り出せます。
    結果として、SimplePlayerMover のようなスクリプトは読みやすく保守しやすくなります。
  • プレハブ化して他キャラクターにも簡単に流用できる
    一度プレイヤープレハブに CornerCorrection を組み込んでおけば、
    似た挙動が必要な敵キャラクターや NPC にも、そのままコンポーネントを追加するだけでOKです。
    「ジャンプする敵が天井で引っかかる」といった問題も同じ仕組みで解決できます。
  • レベルデザインの自由度が上がる
    角で引っかかりにくくなることで、
    「ギリギリ通れるトンネル」や「梁の下をすり抜けるアクション」など、
    細かいジオメトリを使ったステージを作りやすくなります。
    また、プレイヤーのカプセルを極端に小さくしなくても済むので、
    当たり判定の調整が楽になります。

応用としては、

  • 2D(Rigidbody2D)版の CornerCorrection を作る
  • 補正時にアニメーションイベントやエフェクトを出す
  • 「角補正をしてもよい天井」と「してはいけない天井」をレイヤーで分ける

といった方向も考えられます。

改造案:補正時に一時的に横速度をブーストする

「角から抜けるときに、少しだけ横に押し出される感じが欲しい」場合、
移動コンポーネント側で次のような処理を追加しても面白いです。


/// <summary>角補正が行われた直後に呼び出して、横方向の速度を少しブーストする例。</summary>
private void BoostHorizontalAfterCorner(ref Vector3 velocity, Vector3 moveDirection, float boostPower = 1.5f)
{
    // 上方向成分はそのままにして、水平成分だけブースト
    Vector3 horizontal = new Vector3(velocity.x, 0f, velocity.z);
    if (horizontal.sqrMagnitude < 0.0001f) return;

    Vector3 boosted = moveDirection.normalized * horizontal.magnitude * boostPower;
    velocity = new Vector3(boosted.x, velocity.y, boosted.z);
}

このように、小さな機能をコンポーネントとして独立させておけば、
「補正時の演出を足す」「補正の条件を変える」といった拡張もしやすくなります。
巨大な God クラスにすべてを書き込まず、「角補正は CornerCorrection に任せる」という分業スタイルを意識してみてください。