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
}
使い方の手順
ここでは「プレイヤーがフックショットで壁や天井に飛び移る」例で説明します。
-
プレイヤーに必要なコンポーネントを用意する
- プレイヤーのルートオブジェクト(例:
Player)にRigidbodyを追加(Use Gravity ON, Constraintsはお好みで) - 同じオブジェクト、または子オブジェクトに
GrapplingHookスクリプトをアタッチ GrapplingHookのインスペクタで- Target Rigidbody にプレイヤーの
Rigidbodyをドラッグ - Hook Origin に「手」や「銃口」のTransformを指定(なければプレイヤー自身でもOK)
- Target Rigidbody にプレイヤーの
- プレイヤーのルートオブジェクト(例:
-
フック可能なオブジェクトを用意する
- 壁や天井など、フックを刺したいオブジェクトに
Collider(Box, Mesh など)を追加 - 必要なら専用レイヤー(例:
GrappleSurface)を作成し、そのオブジェクトに設定 GrapplingHookの Grapple Layer Mask で、そのレイヤーだけに限定しておくと誤ヒットを防げます
- 壁や天井など、フックを刺したいオブジェクトに
-
入力設定
2パターンあります。
-
A. 旧Input(簡単)
useInputSystemのチェックを外す- 左クリック押下でフック開始、離すと解除されます
-
B. 新Input System(推奨)
- プレイヤーのルートに
PlayerInputを追加し、Input Actions アセットを設定 - アクションマップ内に
Grappleという名前のアクションを作成し、マウス左ボタンやゲームパッドボタンにバインド GrapplingHookのuseInputSystemにチェックを入れる- スクリプト内で
playerInput.actions["Grapple"]を参照しているので、アクション名を合わせておきましょう
- プレイヤーのルートに
-
A. 旧Input(簡単)
-
プレイして動作確認
- ゲームを再生し、フック可能な壁をマウスでクリック
- ロープが伸び、プレイヤーがその地点に引っ張られていけば成功です
- 届かない距離やレイヤー外をクリックした場合は何も起こりません
同じコンポーネントを「敵の忍者」「動く足場」「ぶら下がるフック付きオブジェクト」などにもアタッチすれば、
それぞれが独立してフック移動できるようになります。移動ロジックは別スクリプトに分けておけば、「歩く敵」+「フック移動できる敵」のような組み合わせも簡単ですね。
メリットと応用
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つの責務としてコンポーネント化しておき、
必要に応じて小さなメソッドを足していくスタイルにしておくと、後からの拡張がとても楽になります。
ぜひ自分のプロジェクト用に、少しずつ改造しながら育ててみてください。
