Unityで敵AIを書くとき、つい Update() の中に「プレイヤーを追いかける」「攻撃する」「アニメーションを切り替える」など、全部まとめて書いてしまいがちですよね。最初は動くので満足できますが、少し仕様が変わるだけでコード全体をいじることになり、バグも増えます。
とくに「ちょっと賢そうな動き」をさせようとすると、Update() が if 文と計算だらけの God クラスになってしまいます。
そこでこの記事では、「プレイヤーに向かってまっすぐ突っ込む」のではなく、「横へ回り込みながら距離を詰める」動きを 1 コンポーネントに閉じ込めた FlankTactic コンポーネントを紹介します。敵の「移動ロジック」だけをこのスクリプトに任せることで、攻撃やアニメーションは別コンポーネントに分離しやすくなります。
【Unity】横からじわじわ詰める賢い敵AI!「FlankTactic」コンポーネント
以下が、Unity6(C#)で動作するフルコードです。
Rigidbody ベースの 3D キャラクターを想定していますが、2D でも「Y を固定する」など少し変えるだけで応用できます。
using UnityEngine;
/// <summary>
/// プレイヤーに対して、正面から突っ込まずに
/// 横方向へ回り込みながら距離を詰める移動AI。
///
/// ・移動だけを担当するコンポーネント
/// ・攻撃やアニメーションは別コンポーネントに分ける想定
/// </summary>
[RequireComponent(typeof(Rigidbody))]
public class FlankTactic : MonoBehaviour
{
// --- 参照系 ---
[Header("ターゲット設定")]
[SerializeField]
private Transform target; // 追いかける対象(例: プレイヤー)
[Tooltip("ターゲットが指定されていない場合、タグで自動検索するかどうか")]
[SerializeField]
private bool autoFindTargetByTag = true;
[Tooltip("autoFindTargetByTag が true のときに検索するタグ名")]
[SerializeField]
private string targetTag = "Player";
// --- パラメータ ---
[Header("移動パラメータ")]
[Tooltip("前進(ターゲットへ向かう)速度")]
[SerializeField]
private float forwardSpeed = 3.0f;
[Tooltip("横方向(回り込み)速度")]
[SerializeField]
private float strafeSpeed = 4.0f;
[Tooltip("ターゲットとの理想的な距離。これより近づきすぎないようにする")]
[SerializeField]
private float desiredDistance = 4.0f;
[Tooltip("desiredDistance からどれくらいの誤差を許容するか")]
[SerializeField]
private float distanceTolerance = 0.5f;
[Header("回り込みの挙動")]
[Tooltip("回り込む方向をランダムに決めるか(true: ランダム, false: 常に左回り)")]
[SerializeField]
private bool randomizeFlankDirection = true;
[Tooltip("回り込み方向を切り替える時間間隔(秒)")]
[SerializeField]
private float changeDirectionInterval = 3.0f;
[Tooltip("ターゲットを見失う最大距離(これを超えたら停止)")]
[SerializeField]
private float maxChaseDistance = 30.0f;
[Header("回転設定")]
[Tooltip("ターゲット方向へ向きを補正する角速度(度/秒)")]
[SerializeField]
private float turnSpeed = 360.0f;
[Tooltip("Y軸だけで回転させる(3D用)。false にすると完全にLookRotationする")]
[SerializeField]
private bool rotateOnlyOnY = true;
// --- 内部状態 ---
private Rigidbody rb;
// +1: 右回り, -1: 左回り
private float flankDirection = 1.0f;
// 回り込み方向を切り替えるためのタイマー
private float directionTimer = 0.0f;
// 地面に沿って動かしたい場合のための高さ固定オプション
[Header("高度制御(任意)")]
[Tooltip("Y座標を固定するかどうか(3Dの地面上を想定)")]
[SerializeField]
private bool lockYPosition = true;
[Tooltip("lockYPosition が true のときに固定する Y 座標値")]
[SerializeField]
private float fixedY = 0.0f;
private void Awake()
{
rb = GetComponent<Rigidbody>();
// 物理挙動をスクリプト制御に寄せるため、回転は物理から切り離す
rb.freezeRotation = true;
}
private void Start()
{
// ターゲットが未設定なら、タグから自動検索
if (target == null && autoFindTargetByTag)
{
GameObject found = GameObject.FindGameObjectWithTag(targetTag);
if (found != null)
{
target = found.transform;
}
}
// 回り込み方向の初期化
if (randomizeFlankDirection)
{
flankDirection = Random.value > 0.5f ? 1.0f : -1.0f;
}
else
{
flankDirection = -1.0f; // デフォルトは左回り
}
directionTimer = changeDirectionInterval;
}
private void FixedUpdate()
{
// ターゲットがいなければ何もしない
if (target == null)
{
// ここで何もしないことで、他のコンポーネントに任せられる
rb.velocity = Vector3.zero;
return;
}
Vector3 toTarget = target.position - transform.position;
float distanceToTarget = toTarget.magnitude;
// 最大追跡距離を超えたら停止
if (distanceToTarget > maxChaseDistance)
{
rb.velocity = Vector3.zero;
return;
}
// ターゲットへの正規化方向ベクトル
Vector3 dirToTarget = toTarget.normalized;
// 回り込み用の横方向ベクトルを計算
// 3D想定: 上方向はワールドのY軸
Vector3 worldUp = Vector3.up;
// ターゲット方向と上方向から、横方向(右方向)を計算
Vector3 right = Vector3.Cross(worldUp, dirToTarget).normalized;
// flankDirection によって左右どちらに回り込むか決定
Vector3 strafeDir = right * flankDirection;
// 前進方向と回り込み方向を合成
Vector3 moveDir = Vector3.zero;
// 距離に応じて前進 or 後退 or ほぼ停止を決める
float distanceDelta = distanceToTarget - desiredDistance;
if (Mathf.Abs(distanceDelta) > distanceTolerance)
{
// 離れすぎている: 前進成分を追加
if (distanceDelta > 0f)
{
moveDir += dirToTarget * forwardSpeed;
}
// 近づきすぎている: 後退成分を追加
else
{
moveDir -= dirToTarget * forwardSpeed;
}
}
// 常に回り込み成分を追加(横移動)
moveDir += strafeDir * strafeSpeed;
// 実際の速度として適用
Vector3 velocity = moveDir;
// Y座標を固定したい場合は、垂直方向の速度を 0 にする
if (lockYPosition)
{
velocity.y = 0f;
}
rb.velocity = velocity;
// 高さを固定するオプション
if (lockYPosition)
{
Vector3 pos = transform.position;
pos.y = fixedY;
transform.position = pos;
}
// 向きの制御(ターゲットの方向を向かせる)
RotateTowardsTarget(dirToTarget);
// 回り込み方向の切り替え制御
UpdateFlankDirection(distanceToTarget, distanceDelta);
}
/// <summary>
/// ターゲット方向へスムーズに回転させる
/// </summary>
/// <param name="dirToTarget">ターゲットへの正規化ベクトル</param>
private void RotateTowardsTarget(Vector3 dirToTarget)
{
if (dirToTarget.sqrMagnitude <= 0.0001f)
{
return;
}
Quaternion targetRot;
if (rotateOnlyOnY)
{
// Y軸のみで回転させる(水平面上で向きを変える)
Vector3 flatDir = new Vector3(dirToTarget.x, 0f, dirToTarget.z);
if (flatDir.sqrMagnitude <= 0.0001f)
{
return;
}
targetRot = Quaternion.LookRotation(flatDir, Vector3.up);
}
else
{
// 完全にターゲット方向を向く
targetRot = Quaternion.LookRotation(dirToTarget, Vector3.up);
}
transform.rotation = Quaternion.RotateTowards(
transform.rotation,
targetRot,
turnSpeed * Time.fixedDeltaTime
);
}
/// <summary>
/// 回り込み方向の変更ロジック。
/// 一定間隔ごと、または距離が近すぎる場合に方向を反転させるなど。
/// </summary>
private void UpdateFlankDirection(float distanceToTarget, float distanceDelta)
{
directionTimer -= Time.fixedDeltaTime;
bool shouldFlip = false;
// 一定時間ごとに方向を変える
if (directionTimer <= 0f)
{
shouldFlip = true;
directionTimer = changeDirectionInterval;
}
// 近づきすぎている場合は、たまに方向を変えると詰まりにくくなる
if (Mathf.Abs(distanceDelta) < distanceTolerance * 0.5f)
{
// 近すぎるときは、確率的に方向変更
if (Random.value < 0.02f) // 2% の確率で方向転換
{
shouldFlip = true;
}
}
if (shouldFlip)
{
flankDirection *= -1.0f;
}
}
/// <summary>
/// 外部からターゲットを動的に設定したい場合用のメソッド。
/// 例: スポーン時に GameManager から呼ぶ。
/// </summary>
public void SetTarget(Transform newTarget)
{
target = newTarget;
}
}
使い方の手順
ここでは「3Dアクションゲームで、プレイヤーを横からじわじわ詰めてくる敵」を例に手順を説明します。
-
敵キャラクターの準備
- ヒエラルキー上で敵用の GameObject(例:
EnemyFlanker)を作成します。 - 敵オブジェクトに
Rigidbodyコンポーネントを追加します。Use Gravityは地面があるなら ON のままでOK。ConstraintsのFreeze Rotationはすべて ON にしておくと、物理での回転ブレを防げます(スクリプトでもfreezeRotation = trueにしています)。
- 必要であれば
CapsuleColliderなどのコライダーも追加します。
- ヒエラルキー上で敵用の GameObject(例:
-
FlankTactic コンポーネントをアタッチ
- 上記の
FlankTactic.csをAssetsフォルダに保存します。 - 敵オブジェクトにドラッグ&ドロップしてアタッチします。
- Inspector で以下のパラメータを調整します:
Forward Speed: プレイヤーへ寄っていく速度(例: 3〜5)Strafe Speed: 横移動(回り込み)速度(例: 4〜6)Desired Distance: プレイヤーとの理想距離(例: 4)Max Chase Distance: 追跡をやめる距離(例: 30)
- 上記の
-
プレイヤーの設定
- プレイヤーの GameObject に
Playerタグを付けます(autoFindTargetByTagを使う場合)。 - タグ検索ではなく明示的に指定したい場合は、
FlankTacticのTargetフィールドにプレイヤーの Transform をドラッグ&ドロップします。
- プレイヤーの GameObject に
-
シーンでテスト
- プレイヤーをキーボードやゲームパッドで動かせるようにしておきます(Input System など)。
- 再生すると、敵がプレイヤーに対して正面からではなく、横方向へ移動しながら距離を詰めてきます。
- 複数の敵に同じ
FlankTacticを付けると、自然に横に散りながらプレイヤーを囲みに来るような挙動になります。
応用例としては、
- 遠距離攻撃を持つ敵: 一定距離を保ちながら横移動だけさせることで、常に側面から弾を撃ってくる敵にできます。
- ボスの取り巻き: ボス本体は別の AI に任せて、取り巻きだけ
FlankTacticでプレイヤーの周りを回らせると、見た目が一気に派手になります。 - 動く足場: ターゲットをプレイヤーではなく別のオブジェクトにして、回り込むように動く足場を作ることもできます。
メリットと応用
FlankTactic コンポーネントの役割は「ターゲットに対する移動パターン」だけに絞っています。そのため、
- 「移動」は
FlankTactic - 「攻撃」は
EnemyShooterなど別コンポーネント - 「アニメーション制御」は
EnemyAnimatorControllerなど別コンポーネント
というように、責務ごとにスクリプトを分割しやすくなります。
プレハブとしても「この敵は FlankTactic + 近接攻撃」「この敵は FlankTactic + 遠距離攻撃」など、組み合わせでバリエーションを増やせるので、レベルデザインがとても楽になります。
また、desiredDistance や strafeSpeed をちょっと変えるだけで「近距離でグイグイくる奴」「遠距離でチクチクする奴」などのキャラクター性を出せるのもポイントですね。
最後に、簡単な「改造案」として、プレイヤーが見えているときだけ回り込み、見えていないときは停止する機能を足す場合のサンプルです。
/// <summary>
/// プレイヤーが視界に入っているかどうかを判定する簡易メソッド。
/// (FlankTactic に追記して使う想定)
/// </summary>
private bool IsTargetVisible(float viewAngle = 120f, float viewDistance = 20f)
{
if (target == null) return false;
Vector3 toTarget = target.position - transform.position;
float distance = toTarget.magnitude;
if (distance > viewDistance) return false;
Vector3 dirToTarget = toTarget.normalized;
float angle = Vector3.Angle(transform.forward, dirToTarget);
if (angle > viewAngle * 0.5f) return false;
// レイキャストで障害物チェック(壁越しには見えないようにする)
if (Physics.Raycast(transform.position + Vector3.up * 0.5f,
dirToTarget,
out RaycastHit hit,
viewDistance))
{
// ターゲットにヒットしたら視界内とみなす
return hit.transform == target;
}
return false;
}
このメソッドを FixedUpdate() の最初で呼び出し、IsTargetVisible() が false のときは rb.velocity = Vector3.zero; で止めるようにすれば、「見えているときだけ回り込む」AI になります。
このように、小さな責務ごとのコンポーネントを積み重ねていくと、コードもプレハブもどんどん管理しやすくなっていきます。ぜひ自分のゲーム用にパラメータやロジックをカスタマイズしてみてください。
