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アクションゲームで、プレイヤーを横からじわじわ詰めてくる敵」を例に手順を説明します。

  1. 敵キャラクターの準備
    • ヒエラルキー上で敵用の GameObject(例: EnemyFlanker)を作成します。
    • 敵オブジェクトに Rigidbody コンポーネントを追加します。
      • Use Gravity は地面があるなら ON のままでOK。
      • ConstraintsFreeze Rotation はすべて ON にしておくと、物理での回転ブレを防げます(スクリプトでも freezeRotation = true にしています)。
    • 必要であれば CapsuleCollider などのコライダーも追加します。
  2. FlankTactic コンポーネントをアタッチ
    • 上記の FlankTactic.csAssets フォルダに保存します。
    • 敵オブジェクトにドラッグ&ドロップしてアタッチします。
    • Inspector で以下のパラメータを調整します:
      • Forward Speed: プレイヤーへ寄っていく速度(例: 3〜5)
      • Strafe Speed: 横移動(回り込み)速度(例: 4〜6)
      • Desired Distance: プレイヤーとの理想距離(例: 4)
      • Max Chase Distance: 追跡をやめる距離(例: 30)
  3. プレイヤーの設定
    • プレイヤーの GameObject に Player タグを付けます(autoFindTargetByTag を使う場合)。
    • タグ検索ではなく明示的に指定したい場合は、FlankTacticTarget フィールドにプレイヤーの Transform をドラッグ&ドロップします。
  4. シーンでテスト
    • プレイヤーをキーボードやゲームパッドで動かせるようにしておきます(Input System など)。
    • 再生すると、敵がプレイヤーに対して正面からではなく、横方向へ移動しながら距離を詰めてきます。
    • 複数の敵に同じ FlankTactic を付けると、自然に横に散りながらプレイヤーを囲みに来るような挙動になります。

応用例としては、

  • 遠距離攻撃を持つ敵: 一定距離を保ちながら横移動だけさせることで、常に側面から弾を撃ってくる敵にできます。
  • ボスの取り巻き: ボス本体は別の AI に任せて、取り巻きだけ FlankTactic でプレイヤーの周りを回らせると、見た目が一気に派手になります。
  • 動く足場: ターゲットをプレイヤーではなく別のオブジェクトにして、回り込むように動く足場を作ることもできます。

メリットと応用

FlankTactic コンポーネントの役割は「ターゲットに対する移動パターン」だけに絞っています。そのため、

  • 「移動」は FlankTactic
  • 「攻撃」は EnemyShooter など別コンポーネント
  • 「アニメーション制御」は EnemyAnimatorController など別コンポーネント

というように、責務ごとにスクリプトを分割しやすくなります。
プレハブとしても「この敵は FlankTactic + 近接攻撃」「この敵は FlankTactic + 遠距離攻撃」など、組み合わせでバリエーションを増やせるので、レベルデザインがとても楽になります。

また、desiredDistancestrafeSpeed をちょっと変えるだけで「近距離でグイグイくる奴」「遠距離でチクチクする奴」などのキャラクター性を出せるのもポイントですね。

最後に、簡単な「改造案」として、プレイヤーが見えているときだけ回り込み、見えていないときは停止する機能を足す場合のサンプルです。


/// <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 になります。

このように、小さな責務ごとのコンポーネントを積み重ねていくと、コードもプレハブもどんどん管理しやすくなっていきます。ぜひ自分のゲーム用にパラメータやロジックをカスタマイズしてみてください。