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);
}
}
使い方の手順
-
NavMesh の準備
RTS や MOBA 風のクリック移動にはNavMeshAgentを使うので、まずは地形に NavMesh を焼き込みます。- 地面にしたいオブジェクト(Plane, Terrain など)に
Navigation Staticを設定 - メニューから
Window > AI > Navigationを開き、BakeタブでBakeボタンを押す
- 地面にしたいオブジェクト(Plane, Terrain など)に
-
ユニット用プレハブを作成
プレイヤーやユニットとなる GameObject を用意します。- 空の GameObject を作成し、モデルや Capsule などを子オブジェクトとして配置
- 親オブジェクトに
NavMeshAgentコンポーネントを追加 SpeedやAngular Speedなど、移動パラメータをお好みで調整- 同じ親オブジェクトに、この
ClickToMoveコンポーネントを追加
これだけで「クリックされた場所に向かって移動するユニット」の土台ができます。
-
カメラとレイヤーを設定
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 にすると、最後にクリックした目的地に小さな球が表示されます
- Raycast Camera : 通常はメインカメラを指定(未設定なら自動で
-
プレイして動作確認
再生ボタンを押して、シーンビューまたはゲームビュー上で地面をクリックしてみましょう。- ユニットが NavMesh 上を経路探索しながらクリック地点に移動するはずです
- プレイヤーキャラ用に使えば「見下ろし視点のクリック移動アクション」になります
- 敵ユニットに付ければ、「デバッグ時にクリックした場所へ敵を移動させて挙動を確認する」ツールとしても便利です
メリットと応用
ClickToMove コンポーネントの責務は「クリック入力 → Raycast → NavMeshAgent.SetDestination」だけに絞っています。このシンプルさが、プレハブ管理やレベルデザインをかなり楽にしてくれます。
- プレハブがそのまま「クリック移動ユニット」になる
プレハブにNavMeshAgentとClickToMoveを付けておけば、どのシーンに配置しても「クリックした場所へ移動するユニット」として即戦力になります。カメラや入力の処理はこのコンポーネントに閉じているので、他のスクリプトをいじる必要がありません。 - 責務分離で 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 との連携なども局所的な修正で済みます。クリック移動ロジックを巨大なプレイヤースクリプトから切り離して、扱いやすいコンポーネントとして育てていきましょう。
