Unityを触り始めた頃、「とりあえず全部 Update() に書けば動くし楽じゃん!」となりがちですよね。
移動、ジャンプ、カメラ、エフェクト、UI更新、そして今回のようなフックショット処理まで、全部1つのスクリプトに押し込んでしまうと…

  • ちょっと仕様を変えたいだけなのに、巨大な Update() をスクロールしまくる
  • 別プロジェクトで「フックだけ再利用したい」と思っても、他の処理と密結合で切り出せない
  • バグの原因を探すのが地獄になる

そこで今回は、「フックショット(グラップリングフック)」の機能だけを1つのコンポーネントに切り出してみましょう。
プレイヤーにアタッチするだけで、クリック位置にロープを伸ばし、ヒットした地点にプレイヤーを牽引できる 「GrapplingHook」コンポーネント を作っていきます。

【Unity】クリックで飛び移るグラップリングアクション!「GrapplingHook」コンポーネント

このコンポーネントは次のようなことをしてくれます:

  • マウスクリック位置に向かってレイキャストを飛ばす
  • 指定レイヤーにヒットしたら、その地点を「フックポイント」として記録
  • プレイヤー(親オブジェクト)をその地点へ物理的に牽引する
  • ロープの描画(LineRenderer)を自動制御

プレイヤーの移動処理とは独立しているので、「フックできるキャラ」「フックできないキャラ」をプレハブ単位で簡単に切り替えられるようになります。


GrapplingHook.cs フルコード


using UnityEngine;
using UnityEngine.InputSystem; // 新Input Systemを使う場合に必要

/// <summary>
/// マウスクリック位置に向けてレイを飛ばし、
/// ヒットした位置へ親オブジェクト(通常はプレイヤー)を牽引するコンポーネント。
/// LineRendererでロープの描画も行う。
/// </summary>
[RequireComponent(typeof(LineRenderer))]
public class GrapplingHook : MonoBehaviour
{
    // ==== インスペクタ設定項目 ====

    [Header("必須参照")]
    [SerializeField]
    private Transform hookOrigin; 
    // ロープの発射位置(例:プレイヤーの手、武器の先端)
    // 未設定の場合は、このコンポーネントのTransformを使用

    [SerializeField]
    private Rigidbody targetRigidbody;
    // 牽引されるRigidbody(通常はプレイヤー本体のRigidbody)
    // 未設定の場合は、親階層から自動取得を試みる

    [Header("入力設定")]
    [SerializeField]
    private bool useInputSystem = true;
    // true: 新Input Systemを使用
    // false: 旧Input.GetMouseButton系を使用(簡易)

    [SerializeField]
    private string grappleActionName = "Fire1";
    // 旧InputSystem用: Input Managerのボタン名(例: "Fire1")

    [Header("グラップリング設定")]
    [SerializeField]
    private float maxDistance = 30f;
    // レイキャストの最大距離

    [SerializeField]
    private LayerMask grappleLayerMask = ~0;
    // フック可能なレイヤー(デフォルトは全て)

    [SerializeField]
    private float pullSpeed = 10f;
    // 牽引速度(毎秒どれだけ近づくか)

    [SerializeField]
    private float stopDistance = 1.0f;
    // フックポイントにどれだけ近づいたら牽引を終了するか

    [SerializeField]
    private AnimationCurve pullSpeedCurve = AnimationCurve.Linear(0, 1, 1, 1);
    // 牽引の速度カーブ(0:開始時, 1:終了時)

    [Header("ロープ描画設定")]
    [SerializeField]
    private Color ropeColor = Color.white;

    [SerializeField]
    private float ropeWidth = 0.05f;

    [SerializeField]
    private bool smoothRope = true;
    // trueにすると、移動中もLineRendererを毎フレーム更新

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

    private LineRenderer lineRenderer;

    private Vector3 grapplePoint;
    private bool isGrappling = false;
    private float currentGrappleDistance;
    private float initialGrappleDistance;

    // 新Input System用
    private PlayerInput playerInput;
    private InputAction grappleAction;

    private void Awake()
    {
        // LineRendererの取得および初期設定
        lineRenderer = GetComponent<LineRenderer>();
        lineRenderer.enabled = false;
        lineRenderer.positionCount = 2;
        lineRenderer.startWidth = ropeWidth;
        lineRenderer.endWidth = ropeWidth;
        lineRenderer.material = new Material(Shader.Find("Sprites/Default"));
        lineRenderer.startColor = ropeColor;
        lineRenderer.endColor = ropeColor;

        // hookOriginが未設定なら自分自身
        if (hookOrigin == null)
        {
            hookOrigin = transform;
        }

        // Rigidbodyが未設定なら、親階層から探索
        if (targetRigidbody == null)
        {
            targetRigidbody = GetComponentInParent<Rigidbody>();
        }

        if (targetRigidbody == null)
        {
            Debug.LogWarning("[GrapplingHook] 牽引対象のRigidbodyが見つかりません。インスペクタで設定してください。", this);
        }

        // 新Input System用の設定
        if (useInputSystem)
        {
            playerInput = GetComponentInParent<PlayerInput>();
            if (playerInput != null)
            {
                // アクションマップから "Grapple" などのアクションを取得する想定
                // ここでは、"Grapple" という名前のアクションを使う例
                grappleAction = playerInput.actions["Grapple"];
            }
            else
            {
                Debug.LogWarning("[GrapplingHook] PlayerInput が見つからないため、Input Systemによる操作は無効になります。", this);
                useInputSystem = false;
            }
        }
    }

    private void OnEnable()
    {
        // 新Input Systemのイベント購読
        if (useInputSystem && grappleAction != null)
        {
            grappleAction.performed += OnGrapplePerformed;
            grappleAction.canceled += OnGrappleCanceled;
        }
    }

    private void OnDisable()
    {
        // イベント購読解除
        if (useInputSystem && grappleAction != null)
        {
            grappleAction.performed -= OnGrapplePerformed;
            grappleAction.canceled -= OnGrappleCanceled;
        }
    }

    private void Update()
    {
        // 旧InputSystem(Input.GetMouseButton)での入力処理
        if (!useInputSystem)
        {
            HandleLegacyInput();
        }

        // 牽引処理
        if (isGrappling && targetRigidbody != null)
        {
            UpdateGrappleMovement();
        }

        // ロープ描画の更新
        if (isGrappling && smoothRope)
        {
            UpdateRope();
        }
    }

    #region 入力処理

    /// <summary>
    /// 旧Input Systemを使った場合の入力チェック。
    /// 左クリック押下でフック開始、ボタンを離すと解除する例。
    /// </summary>
    private void HandleLegacyInput()
    {
        // 左クリック押下でフック開始
        if (Input.GetMouseButtonDown(0))
        {
            TryStartGrappleFromMousePosition();
        }

        // 左クリックを離したらフック解除
        if (Input.GetMouseButtonUp(0))
        {
            StopGrapple();
        }
    }

    /// <summary>
    /// 新Input System: アクションがperformedになったときに呼ばれる。
    /// </summary>
    /// <param name="context"></param>
    private void OnGrapplePerformed(InputAction.CallbackContext context)
    {
        // マウス位置からグラップル開始
        TryStartGrappleFromMousePosition();
    }

    /// <summary>
    /// 新Input System: アクションがcanceledになったときに呼ばれる。
    /// </summary>
    /// <param name="context"></param>
    private void OnGrappleCanceled(InputAction.CallbackContext context)
    {
        StopGrapple();
    }

    #endregion

    #region グラップリング処理

    /// <summary>
    /// マウスカーソル位置からレイキャストを飛ばし、ヒットした位置にフックを刺す。
    /// </summary>
    private void TryStartGrappleFromMousePosition()
    {
        // カメラが必要なので、メインカメラを使用
        Camera cam = Camera.main;
        if (cam == null)
        {
            Debug.LogWarning("[GrapplingHook] MainCamera が見つかりません。", this);
            return;
        }

        // 画面上のマウス位置からレイを生成
        Ray ray = cam.ScreenPointToRay(Mouse.current != null ? Mouse.current.position.ReadValue() : (Vector2)Input.mousePosition);

        if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, grappleLayerMask, QueryTriggerInteraction.Ignore))
        {
            // ヒットした位置をフックポイントとして記録
            StartGrapple(hit.point);
        }
        else
        {
            // 何もヒットしなかった
            // Debug.Log("Grapple miss");
        }
    }

    /// <summary>
    /// 指定されたワールド座標にグラップルを開始する。
    /// 外部スクリプトからも呼び出せるようにpublicにしておく。
    /// </summary>
    /// <param name="worldPoint">フック先のワールド座標</param>
    public void StartGrapple(Vector3 worldPoint)
    {
        if (targetRigidbody == null)
        {
            Debug.LogWarning("[GrapplingHook] 牽引対象のRigidbodyが設定されていません。", this);
            return;
        }

        grapplePoint = worldPoint;
        isGrappling = true;

        // 距離情報を初期化(速度カーブ用)
        initialGrappleDistance = Vector3.Distance(targetRigidbody.position, grapplePoint);
        currentGrappleDistance = initialGrappleDistance;

        // ロープ描画の初期更新
        lineRenderer.enabled = true;
        UpdateRope();
    }

    /// <summary>
    /// グラップルを終了し、ロープ描画も止める。
    /// </summary>
    public void StopGrapple()
    {
        isGrappling = false;
        lineRenderer.enabled = false;
    }

    /// <summary>
    /// 牽引中の移動処理。
    /// Rigidbodyを使ってフックポイントへ近づける。
    /// </summary>
    private void UpdateGrappleMovement()
    {
        Vector3 currentPosition = targetRigidbody.position;
        Vector3 toTarget = grapplePoint - currentPosition;
        float distance = toTarget.magnitude;

        if (distance <= stopDistance)
        {
            // ある程度近づいたら停止
            StopGrapple();
            return;
        }

        // 0~1の正規化距離(1:開始時、0:終了近く)
        float t = Mathf.InverseLerp(initialGrappleDistance, 0f, distance);
        float curveMultiplier = pullSpeedCurve.Evaluate(t);

        // 正規化方向ベクトル
        Vector3 direction = toTarget.normalized;

        // 速度ベクトルを計算
        Vector3 desiredVelocity = direction * pullSpeed * curveMultiplier;

        // Rigidbodyの速度を直接設定(他の移動処理と組み合わせる場合はAddForceに変更も可)
        targetRigidbody.velocity = new Vector3(
            desiredVelocity.x,
            desiredVelocity.y,
            desiredVelocity.z
        );

        currentGrappleDistance = distance;
    }

    /// <summary>
    /// LineRendererを使ってロープを描画する。
    /// </summary>
    private void UpdateRope()
    {
        if (!lineRenderer.enabled)
        {
            return;
        }

        // 始点: フック発射位置
        Vector3 start = hookOrigin != null ? hookOrigin.position : transform.position;

        // 終点: フックポイント
        Vector3 end = grapplePoint;

        lineRenderer.SetPosition(0, start);
        lineRenderer.SetPosition(1, end);
    }

    #endregion

    #region デバッグ用ギズモ

    private void OnDrawGizmosSelected()
    {
        // 最大距離の可視化(シーンビュー上)
        Gizmos.color = Color.cyan;
        Gizmos.DrawWireSphere(transform.position, maxDistance);
    }

    #endregion
}

使い方の手順

ここでは「プレイヤーがフックショットで壁や天井に飛び移る」例で説明します。

  1. プレイヤーに必要なコンポーネントを用意する
    • プレイヤーのルートオブジェクト(例:Player)に Rigidbody を追加(Use Gravity ON, Constraintsはお好みで)
    • 同じオブジェクト、または子オブジェクトに GrapplingHook スクリプトをアタッチ
    • GrapplingHook のインスペクタで
      • Target Rigidbody にプレイヤーの Rigidbody をドラッグ
      • Hook Origin に「手」や「銃口」のTransformを指定(なければプレイヤー自身でもOK)
  2. フック可能なオブジェクトを用意する
    • 壁や天井など、フックを刺したいオブジェクトに Collider(Box, Mesh など)を追加
    • 必要なら専用レイヤー(例:GrappleSurface)を作成し、そのオブジェクトに設定
    • GrapplingHookGrapple Layer Mask で、そのレイヤーだけに限定しておくと誤ヒットを防げます
  3. 入力設定

    2パターンあります。

    • A. 旧Input(簡単)
      • useInputSystem のチェックを外す
      • 左クリック押下でフック開始、離すと解除されます
    • B. 新Input System(推奨)
      • プレイヤーのルートに PlayerInput を追加し、Input Actions アセットを設定
      • アクションマップ内に Grapple という名前のアクションを作成し、マウス左ボタンやゲームパッドボタンにバインド
      • GrapplingHookuseInputSystem にチェックを入れる
      • スクリプト内で playerInput.actions["Grapple"] を参照しているので、アクション名を合わせておきましょう
  4. プレイして動作確認
    • ゲームを再生し、フック可能な壁をマウスでクリック
    • ロープが伸び、プレイヤーがその地点に引っ張られていけば成功です
    • 届かない距離やレイヤー外をクリックした場合は何も起こりません

同じコンポーネントを「敵の忍者」「動く足場」「ぶら下がるフック付きオブジェクト」などにもアタッチすれば、
それぞれが独立してフック移動できるようになります。移動ロジックは別スクリプトに分けておけば、「歩く敵」+「フック移動できる敵」のような組み合わせも簡単ですね。


メリットと応用

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

  • プレハブ単位で機能ON/OFFできる
    プレイヤー用プレハブにアタッチすれば「フック可能プレイヤー」に、敵プレハブにアタッチすれば「フック移動する敵」に、といった具合に再利用できます。
  • レベルデザインが楽になる
    フック可能な足場は「Collider+対象レイヤーを設定するだけ」。
    ステージデザイナーは「どこにフックできるか」だけを意識すればよく、スクリプトには触らなくて済みます。
  • Godクラス化を防げる
    プレイヤーの移動、カメラ、UI、武器管理などからフック機能を切り離すことで、各スクリプトの責務が明確になります。
    「フックの挙動だけ修正したい」ときに、このコンポーネントだけを見れば済むのは精神衛生的にもかなり楽です。

さらに、ちょっとした改造を加えるだけで、ゲーム性を大きく変えられます。

  • フック中は重力を弱める/無効にする
  • フック成功時にSEやエフェクトを再生
  • フック先に到達した瞬間にジャンプボタンで「スイングジャンプ」などの派生アクション

例えば、フック開始時に重力をオフにして、終了時に元に戻す簡単な改造はこんな感じです。


    // GrapplingHook クラス内への追記例

    private float originalGravityScale = 1f;
    private bool gravityModified = false;

    private void CacheOriginalGravity()
    {
        if (targetRigidbody != null && !gravityModified)
        {
            // Rigidbodyの重力スケールを保存(Unity標準にはないので、独自に扱う場合はここを拡張)
            originalGravityScale = Physics.gravity.y;
            gravityModified = true;
        }
    }

    private void DisableGravityDuringGrapple()
    {
        if (targetRigidbody != null)
        {
            targetRigidbody.useGravity = false;
        }
    }

    private void RestoreGravity()
    {
        if (targetRigidbody != null)
        {
            targetRigidbody.useGravity = true;
        }
    }

上記のような補助メソッドを用意して、StartGrapple() の中で DisableGravityDuringGrapple() を呼び、
StopGrapple() の中で RestoreGravity() を呼ぶようにすれば、フック中だけふわっとした挙動を簡単に実現できます。

このように、「フック処理」は1つの責務としてコンポーネント化しておき、
必要に応じて小さなメソッドを足していくスタイルにしておくと、後からの拡張がとても楽になります。
ぜひ自分のプロジェクト用に、少しずつ改造しながら育ててみてください。