Unityを触り始めた頃にありがちなのが、プレイヤーの移動も、敵AIも、カメラ制御も、全部ひとつの Update() に書いてしまうパターンです。最初は動くので「これでいいじゃん」と思いがちですが、少し規模が大きくなると問題が一気に噴き出します。
- 敵の巡回ルートを変えたいだけなのに、巨大なスクリプトを開いてスクロール地獄
- 別の敵にも同じ挙動をさせたいのに、コピペだらけでバグ修正が地獄
- レベルデザイナー(=シーンを組む人)が、コードを触らないとルート変更できない
こういった「Godクラス問題」を避けるためには、役割ごとに小さなコンポーネントを分けるのが大事ですね。今回はその一例として、2Dの巡回ルートを担当するだけのコンポーネントを用意して、敵AIの「動き」の部分だけをきれいに切り出してみましょう。
この記事では、Path2Dに設定されたポイントを順番に移動し、端まで来たら折り返すAIを実装するための「PatrolPath」コンポーネントを紹介します。
【Unity】折り返しパトロールを超シンプルに!「PatrolPath」コンポーネント
以下のコンポーネントは、
- シーン上に並べた複数のポイントを順番に移動
- 最後のポイントまで到達したら、逆順に折り返し
- 2D用に
Rigidbody2Dを使った物理フレンドリーな実装
という役割だけに責任を絞った、シンプルな巡回AIです。
フルコード
using UnityEngine;
/// <summary>
/// 2D用の巡回ルートコンポーネント。
/// Path2D(Transform配列)に設定されたポイントを順番に移動し、
/// 端まで到達したら折り返して逆順に移動します。
///
/// 責務は「与えられたポイント列に沿って移動させること」だけに限定しています。
/// 攻撃処理やアニメーション制御は、別コンポーネントに分けることを推奨します。
/// </summary>
[RequireComponent(typeof(Rigidbody2D))]
public class PatrolPath : MonoBehaviour
{
[Header("移動設定")]
[SerializeField] private float moveSpeed = 2.0f; // 巡回速度
[SerializeField] private float reachThreshold = 0.05f; // 目標ポイントに到達したとみなす距離
[Header("巡回ポイント設定")]
[Tooltip("巡回するポイント(順番に辿ります)。シーン上の空オブジェクトを並べて指定すると管理しやすいです。")]
[SerializeField] private Transform[] pathPoints;
[Header("初期設定")]
[Tooltip("開始時に最も近いポイントからスタートするか(true推奨)")]
[SerializeField] private bool startFromClosestPoint = true;
// 内部状態管理用
private Rigidbody2D rb;
private int currentIndex = 0; // 現在向かっているポイントのインデックス
private int direction = 1; // 1: 順方向, -1: 逆方向
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
// Rigidbody2Dの推奨設定(パトロール専用のため)
// ここでデフォルト値を上書きしておくと、プレハブ化したときに一貫性が保てます。
rb.gravityScale = 0f; // 2Dパトロールなので重力は使わない前提
rb.freezeRotation = true; // 回転はさせない(横向きのまま移動したいケースが多いため)
// パスが設定されていない場合は警告を出して無効化
if (pathPoints == null || pathPoints.Length < 2)
{
Debug.LogWarning(
$"[PatrolPath] パスが正しく設定されていません: {name}. " +
"少なくとも2つ以上のポイントを設定してください。",
this);
enabled = false;
return;
}
// 最も近いポイントからスタートするオプション
if (startFromClosestPoint)
{
currentIndex = FindClosestPointIndex(transform.position);
}
else
{
currentIndex = 0;
}
}
private void FixedUpdate()
{
// パスが無効な場合は何もしない
if (pathPoints == null || pathPoints.Length == 0) return;
// 現在のターゲットポイント
Transform targetPoint = pathPoints[currentIndex];
if (targetPoint == null)
{
Debug.LogWarning($"[PatrolPath] pathPoints[{currentIndex}] が null です: {name}", this);
return;
}
// 現在位置とターゲット位置
Vector2 currentPosition = rb.position;
Vector2 targetPosition = targetPoint.position;
// ターゲットまでの方向ベクトル(正規化)
Vector2 toTarget = targetPosition - currentPosition;
float distance = toTarget.magnitude;
// すでに到達しているか判定
if (distance <= reachThreshold)
{
// 次のポイントへ進む
AdvanceToNextPoint();
return; // このフレームでは速度を0にして終了(次フレームから新しいターゲットへ移動)
}
Vector2 directionNormalized = toTarget / distance;
// 移動速度ベクトルを計算
Vector2 velocity = directionNormalized * moveSpeed;
// Rigidbody2D の velocity を直接設定して移動
rb.velocity = velocity;
// 左右反転(スプライトを左右向きに切り替えたい場合)
// x方向の速度で向きを判定し、localScale.x の符号を変える
if (Mathf.Abs(velocity.x) > 0.01f)
{
Vector3 localScale = transform.localScale;
localScale.x = Mathf.Sign(velocity.x) * Mathf.Abs(localScale.x);
transform.localScale = localScale;
}
}
/// <summary>
/// 次のポイントに進む(端に到達したら折り返し)。
/// </summary>
private void AdvanceToNextPoint()
{
// 一旦停止
rb.velocity = Vector2.zero;
// 次のインデックスを計算
int nextIndex = currentIndex + direction;
// 端に到達したか?
if (nextIndex < 0 || nextIndex >= pathPoints.Length)
{
// 方向を反転
direction *= -1;
// 反転後の方向でもう一度計算
nextIndex = currentIndex + direction;
// 念のため範囲チェック(ポイントが1つだけなどの異常系)
nextIndex = Mathf.Clamp(nextIndex, 0, pathPoints.Length - 1);
}
currentIndex = nextIndex;
}
/// <summary>
/// 指定位置から最も近いポイントのインデックスを返します。
/// </summary>
private int FindClosestPointIndex(Vector2 fromPosition)
{
int closestIndex = 0;
float closestSqrDist = float.MaxValue;
for (int i = 0; i < pathPoints.Length; i++)
{
Transform point = pathPoints[i];
if (point == null) continue;
float sqrDist = ((Vector2)point.position - fromPosition).sqrMagnitude;
if (sqrDist < closestSqrDist)
{
closestSqrDist = sqrDist;
closestIndex = i;
}
}
return closestIndex;
}
// --- エディタ上での補助(ギズモ描画)---
private void OnDrawGizmos()
{
if (pathPoints == null || pathPoints.Length == 0) return;
Gizmos.color = Color.cyan;
// ポイント同士を線でつなぐ
for (int i = 0; i < pathPoints.Length - 1; i++)
{
if (pathPoints[i] == null || pathPoints[i + 1] == null) continue;
Gizmos.DrawLine(pathPoints[i].position, pathPoints[i + 1].position);
}
// 各ポイントに球を描画
for (int i = 0; i < pathPoints.Length; i++)
{
if (pathPoints[i] == null) continue;
Gizmos.DrawSphere(pathPoints[i].position, 0.07f);
}
}
}
使い方の手順
ここでは、2Dアクションゲームの「巡回する敵」を例に手順を説明します。
-
シーンに敵キャラクターを配置する
- Hierarchy で
Right Click > Create Emptyから空オブジェクトを作成し、名前をEnemy_Patrolなどに変更。 - スプライト(SpriteRenderer)や Collider2D を追加して、見た目と当たり判定を設定。
Rigidbody2Dを追加(PatrolPathが自動で付けてくれますが、先に付けておいてもOK)。- 上記の
PatrolPath.csをプロジェクトに作成し、Enemy_Patrolにアタッチ。
- Hierarchy で
-
巡回ポイント用の空オブジェクトを作る
- Hierarchy で
Right Click > Create Emptyを2つ以上作成し、Point_A,Point_B,Point_C… のようにリネーム。 - Sceneビューでドラッグして、敵に歩かせたい位置にそれぞれ移動。
- 管理しやすいように、
Enemy_Patrolの子オブジェクトにしておくと見通しが良くなります(必須ではありません)。
- Hierarchy で
-
PatrolPath にポイントを登録する
Enemy_Patrolを選択し、Inspector でPatrolPathコンポーネントを確認。Path Points(pathPoints)配列のサイズを、作成したポイント数に合わせて変更。- 各要素に
Point_A,Point_B,Point_C… をドラッグ&ドロップで割り当て。 Move Speedで巡回速度、Reach Thresholdで「到達したとみなす距離」を調整。
-
動作確認&調整
- Playボタンを押すと、敵が
Point_A → Point_B → Point_C → Point_B → Point_A → …のように折り返しながら移動します。 - ポイントの位置を動かすだけで、巡回ルートを直感的に編集可能です。
- 同じ
PatrolPathコンポーネントを別の敵プレハブにもアタッチすれば、ルートだけ変えて簡単にバリエーションを作れます。
- Playボタンを押すと、敵が
応用例としては、
- 動く床(Moving Platform)に
PatrolPathを付けて、プレイヤーを運ぶリフトにする - 見張りロボットの巡回ルートとして使い、別コンポーネントで視界チェックや攻撃を追加する
- コインやアイテムがふわふわ往復する演出に使う
といった形で、「一定ルートを行ったり来たりするもの」全般に流用できます。
メリットと応用
PatrolPath を使うメリットは、単に「楽に動かせる」だけではありません。
- プレハブ管理がシンプルになる
敵プレハブには「見た目」「当たり判定」「巡回AI」「攻撃AI」など、小さなコンポーネントを積み上げるだけでOKになります。
巡回ロジックを巨大なAIスクリプトに埋め込まず、「動きだけ」を独立させているので、他の敵にも簡単に再利用できます。 - レベルデザインがコードレスで完結する
巡回ルートの変更は、Sceneビューでポイントの位置を動かすだけです。
新しいルートを追加したいときも、空オブジェクトをポンポン置いて、pathPointsに登録するだけで完了します。 - 責務が明確でテストしやすい
このコンポーネントの責務は「ポイント列に沿って移動すること」だけなので、バグの原因を追いやすく、単体テストもしやすくなります。
攻撃やアニメーションは別コンポーネントに分けておけば、「止まっているときだけ攻撃する」「歩いているときだけ歩きモーション」などの制御も組み合わせやすくなります。
さらに、簡単な改造で表現の幅を広げることもできます。例えば、「端まで行ったら少し待ってから折り返す」ようにしたい場合は、こんなメソッドを追加して AdvanceToNextPoint() の代わりに呼び出すとよいでしょう。
/// <summary>
/// 端に到達したときに少し待ってから折り返す例。
/// コルーチンを使って待機時間を挟みます。
/// </summary>
private System.Collections.IEnumerator AdvanceToNextPointWithDelay(float waitSeconds)
{
// いったん停止
rb.velocity = Vector2.zero;
// 端にいるかどうかをチェック
bool isAtEdge = (currentIndex == 0 && direction < 0) ||
(currentIndex == pathPoints.Length - 1 && direction > 0);
if (isAtEdge && waitSeconds > 0f)
{
// 待機
yield return new WaitForSeconds(waitSeconds);
}
// 通常のAdvanceToNextPointロジックをそのまま呼ぶ
AdvanceToNextPoint();
}
このように、小さく責務がはっきりしたコンポーネントにしておくと、あとからの改造や差し替えがとても楽になります。
「とりあえず全部 Update() に書く」から一歩進んで、こうしたパトロール専用コンポーネントを積み重ねていくスタイルに慣れていきましょう。
