Unityを触り始めた頃は、つい Update() に「入力処理」「移動処理」「アニメーション」「エフェクト管理」など、全部まとめて書いてしまいがちですよね。最初は動くのですが、機能が増えるたびに if 文とフラグだらけになり、ちょっとした修正でバグを量産する「Godクラス」状態になってしまいます。

RTS や MOBA 風の操作でよくある「地面をクリックした場所にユニットが移動する」処理も、プレイヤー制御スクリプトに全部詰め込んでしまうと、カメラ制御や攻撃処理と絡み合ってすぐにカオスになります。

そこで今回は、「クリックした地点を NavMeshAgent の目的地にセットするだけ」という責務に絞ったコンポーネント 「ClickToMove」 を用意して、入力と経路移動をきれいに分離してみましょう。

【Unity】クリックした場所へスマート移動!「ClickToMove」コンポーネント

フルコード(ClickToMove.cs)


using UnityEngine;
using UnityEngine.AI;
using UnityEngine.InputSystem; // 新Input System用

/// <summary>
/// 画面をクリックした地点を NavMeshAgent の目的地に設定するコンポーネント。
/// RTS / MOBA 風の「クリック移動」をシンプルに実装します。
/// 
/// 責務は「入力を受け取り、Raycast で地面位置を取得し、NavMeshAgent に目的地を渡す」だけに限定。
/// 実際の移動は NavMeshAgent に任せることで、コンポーネントを小さく保ちます。
/// </summary>
[RequireComponent(typeof(NavMeshAgent))]
public class ClickToMove : MonoBehaviour
{
    // --- 設定項目(インスペクターから調整) ---

    [Header("Raycast 設定")]

    [SerializeField]
    [Tooltip("クリック位置を判定するカメラ。未設定の場合は Camera.main を使用します。")]
    private Camera _raycastCamera;

    [SerializeField]
    [Tooltip("クリックを受け付けるレイヤーマスク(例: 地面レイヤー)。")]
    private LayerMask _groundLayerMask = ~0; // デフォルトは全レイヤー

    [SerializeField]
    [Tooltip("Raycast の最大距離。カメラからこの距離までヒットを検出します。")]
    private float _raycastMaxDistance = 500f;

    [Header("クリック入力設定")]

    [SerializeField]
    [Tooltip("クリックに使用するマウスボタン。0:左, 1:右, 2:中ボタン")]
    private int _mouseButtonIndex = 1; // RTSっぽく右クリックをデフォルトに

    [Header("目的地の補正")]

    [SerializeField]
    [Tooltip("目的地の高さをこのオフセット分だけ調整します(NavMesh の少し上に置きたい場合など)。")]
    private float _destinationHeightOffset = 0.0f;

    [Header("デバッグ表示")]

    [SerializeField]
    [Tooltip("クリックした地点に Gizmo を表示するかどうか。")]
    private bool _drawDebugGizmos = true;

    [SerializeField]
    [Tooltip("Gizmo に使用する色。")]
    private Color _gizmoColor = Color.cyan;

    [SerializeField]
    [Tooltip("Gizmo のサイズ。")]
    private float _gizmoSize = 0.3f;

    // --- 内部状態 ---

    private NavMeshAgent _agent;

    // 最後に設定した目的地(デバッグ用)
    private Vector3 _lastDestination;
    private bool _hasDestination;

    // 新 Input System を使う場合に備えた入力アクション(任意)
    [Header("Input System (任意)")]
    [SerializeField]
    [Tooltip("新Input Systemを使う場合のクリックアクション。型: Button")]
    private InputAction _clickAction;

    private void Awake()
    {
        // 必須コンポーネントを取得
        _agent = GetComponent<NavMeshAgent>();

        // カメラが未設定なら、メインカメラを自動取得
        if (_raycastCamera == null)
        {
            _raycastCamera = Camera.main;
        }

        // InputAction が設定されていれば有効化
        if (_clickAction != null)
        {
            _clickAction.Enable();
        }
    }

    private void OnDestroy()
    {
        if (_clickAction != null)
        {
            _clickAction.Disable();
        }
    }

    private void Update()
    {
        // カメラが存在しない場合は何もしない
        if (_raycastCamera == null)
        {
            return;
        }

        // クリック入力を検出
        bool clicked = GetClickInput();
        if (!clicked)
        {
            return;
        }

        // マウス位置から Ray を飛ばす
        Vector2 screenPos = GetPointerPosition();
        Ray ray = _raycastCamera.ScreenPointToRay(screenPos);

        if (Physics.Raycast(ray, out RaycastHit hit, _raycastMaxDistance, _groundLayerMask))
        {
            // ヒット地点を目的地にする
            Vector3 destination = hit.point;

            // 高さオフセットを適用
            destination.y += _destinationHeightOffset;

            SetDestination(destination);
        }
    }

    /// <summary>
    /// 目的地を NavMeshAgent に設定します。
    /// 外部からも呼べるよう public にしておくと、スクリプトから直接移動命令を出すこともできます。
    /// </summary>
    /// <param name="destination">ワールド座標の目的地</param>
    public void SetDestination(Vector3 destination)
    {
        // NavMesh 上の最近傍点にスナップさせたい場合は、NavMesh.SamplePosition を使う
        if (NavMesh.SamplePosition(destination, out NavMeshHit navHit, 2.0f, NavMesh.AllAreas))
        {
            _agent.SetDestination(navHit.position);
            _lastDestination = navHit.position;
            _hasDestination = true;
        }
        else
        {
            // NavMesh が見つからなかった場合は、そのまま設定するか、ログを出す
            _agent.SetDestination(destination);
            _lastDestination = destination;
            _hasDestination = true;
        }
    }

    /// <summary>
    /// クリック入力を取得する。
    /// 新 Input System が設定されていればそちらを優先し、なければ旧 Input を使う。
    /// </summary>
    private bool GetClickInput()
    {
        // 新Input System が設定されている場合
        if (_clickAction != null)
        {
            // Button アクションの「押された瞬間」
            return _clickAction.WasPerformedThisFrame();
        }

        // 旧 Input System のマウス入力
        // _mouseButtonIndex: 0=左,1=右,2=中
        return Input.GetMouseButtonDown(_mouseButtonIndex);
    }

    /// <summary>
    /// ポインタ(マウス)位置を取得する。
    /// 今回はシンプルにマウス座標のみを扱う。
    /// </summary>
    private Vector2 GetPointerPosition()
    {
        // 新 Input System でポインタ位置を取りたい場合は Mouse.current.position なども使えるが、
        // ここでは旧 Input の値をそのまま使う。どちらのシステムでも動作するようにしておく。
        return Input.mousePosition;
    }

    private void OnDrawGizmos()
    {
        if (!_drawDebugGizmos || !_hasDestination)
        {
            return;
        }

        Gizmos.color = _gizmoColor;
        Gizmos.DrawSphere(_lastDestination, _gizmoSize);
    }
}

使い方の手順

  1. NavMesh の準備
    RTS や MOBA 風のクリック移動には NavMeshAgent を使うので、まずは地形に NavMesh を焼き込みます。

    • 地面にしたいオブジェクト(Plane, Terrain など)に Navigation Static を設定
    • メニューから Window > AI > Navigation を開き、Bake タブで Bake ボタンを押す
  2. ユニット用プレハブを作成
    プレイヤーやユニットとなる GameObject を用意します。

    • 空の GameObject を作成し、モデルや Capsule などを子オブジェクトとして配置
    • 親オブジェクトに NavMeshAgent コンポーネントを追加
    • SpeedAngular Speed など、移動パラメータをお好みで調整
    • 同じ親オブジェクトに、この ClickToMove コンポーネントを追加

    これだけで「クリックされた場所に向かって移動するユニット」の土台ができます。

  3. カメラとレイヤーを設定
    ClickToMove をアタッチした GameObject を選択し、インスペクターで設定します。

    • Raycast Camera : 通常はメインカメラを指定(未設定なら自動で Camera.main を使用)
    • Ground Layer Mask : クリックを受け付けたい「地面」用レイヤーを指定
      • 例: 地面オブジェクトに Ground レイヤーを設定し、マスクも Ground のみにする
    • Mouse Button Index :
      • 0 = 左クリックで移動
      • 1 = 右クリックで移動(RTS / MOBA でよくあるパターン)
    • Destination Height Offset : 目的地の高さを微調整したい場合に使用(通常は 0 でOK)
    • Draw Debug Gizmos : ON にすると、最後にクリックした目的地に小さな球が表示されます
  4. プレイして動作確認
    再生ボタンを押して、シーンビューまたはゲームビュー上で地面をクリックしてみましょう。

    • ユニットが NavMesh 上を経路探索しながらクリック地点に移動するはずです
    • プレイヤーキャラ用に使えば「見下ろし視点のクリック移動アクション」になります
    • 敵ユニットに付ければ、「デバッグ時にクリックした場所へ敵を移動させて挙動を確認する」ツールとしても便利です

メリットと応用

ClickToMove コンポーネントの責務は「クリック入力 → Raycast → NavMeshAgent.SetDestination」だけに絞っています。このシンプルさが、プレハブ管理やレベルデザインをかなり楽にしてくれます。

  • プレハブがそのまま「クリック移動ユニット」になる
    プレハブに NavMeshAgentClickToMove を付けておけば、どのシーンに配置しても「クリックした場所へ移動するユニット」として即戦力になります。カメラや入力の処理はこのコンポーネントに閉じているので、他のスクリプトをいじる必要がありません。
  • 責務分離で God クラスを回避
    攻撃AI、アニメーション、HP 管理などは別コンポーネントとして追加し、ClickToMove はあくまで「目的地の更新」に専念させましょう。例えば、攻撃スクリプト側からは SetDestination() を呼び出すだけで済みます。
  • レベルデザイン時のデバッグにも使える
    敵や味方ユニットに一時的に ClickToMove を付けておくと、「このユニットがここまで来たときの挙動を確認したい」といったときに、クリックだけで好きな場所へ動かせます。NavMesh の通行可能エリアの確認にも便利ですね。

応用として、「特定の UI をクリックしているときは移動を無効にする」「Shift を押しながらクリックで別のモードにする」など、入力条件を変えるだけで簡単に機能拡張できます。

例えば、「Shift キーを押している間だけクリック移動を有効にする」改造案はこんな感じです。


/// <summary>
/// Shift キーが押されているときだけクリック入力を有効にする例。
/// 既存の GetClickInput() の中身を、以下のように差し替えればOKです。
/// </summary>
private bool GetClickInput()
{
    bool shiftPressed = Keyboard.current != null && Keyboard.current.leftShiftKey.isPressed;

    if (!shiftPressed)
    {
        // Shift が押されていないときはクリックを無視
        return false;
    }

    if (_clickAction != null)
    {
        return _clickAction.WasPerformedThisFrame();
    }

    return Input.GetMouseButtonDown(_mouseButtonIndex);
}

このように、小さなコンポーネントとして分割しておくと、入力条件の変更や UI との連携なども局所的な修正で済みます。クリック移動ロジックを巨大なプレイヤースクリプトから切り離して、扱いやすいコンポーネントとして育てていきましょう。