Unityの学習を進めていくと、つい何でもかんでも Update() に書いてしまいがちですよね。プレイヤーの入力、カメラ制御、敵AI、UI更新…と1つのスクリプトが太り続けると、次のような問題が出てきます。

  • どの処理がどの機能なのか分かりにくく、バグの原因を特定しづらい
  • 少し仕様変更したいだけなのに、巨大なスクリプト全体を読む必要がある
  • プレハブごとに挙動を変えたいのに、共通のGodクラスをいじるしかない

とくに「敵の群衆AI」を作ろうとすると、

  • プレイヤーを追いかける処理
  • お互いにぶつからないようにする処理
  • 隊列っぽくまとまる処理

などを1つのスクリプトに押し込めてしまいがちです。結果として「誰も触りたくない巨大AIスクリプト」が生まれてしまいます。

そこでこの記事では、「群衆移動(Boids)」のロジックだけに責務を絞った 「BoidFlocking」コンポーネント を作っていきます。敵の見た目や攻撃ロジックは別コンポーネントに任せ、群れとしての移動だけを担当させることで、

  • 敵プレハブにポン付けするだけで群れっぽい動きが手に入る
  • 群れのパラメータ(まとまり具合・バラけ具合)をインスペクタから調整できる
  • 「群れAI」と「攻撃AI」をコンポーネント単位で分離できる

という、コンポーネント指向らしい設計にしていきましょう。

【Unity】分離・整列・結合で作る群衆AI!「BoidFlocking」コンポーネント

フルコード:BoidFlocking.cs


using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// シーン内の全BoidFlockingを管理する簡易マネージャー。
/// グローバルな配列管理をするだけの小さな責務に分割しています。
/// </summary>
public static class BoidFlockingRegistry
{
    // 現在アクティブなBoidFlockingコンポーネントの一覧
    private static readonly List<BoidFlocking> boids = new List<BoidFlocking>();

    /// <summary>登録処理(OnEnableから呼ばれる想定)</summary>
    public static void Register(BoidFlocking boid)
    {
        if (!boids.Contains(boid))
        {
            boids.Add(boid);
        }
    }

    /// <summary>解除処理(OnDisableから呼ばれる想定)</summary>
    public static void Unregister(BoidFlocking boid)
    {
        boids.Remove(boid);
    }

    /// <summary>読み取り専用の列挙を返す(コピーではなく参照)</summary>
    public static IReadOnlyList<BoidFlocking> AllBoids => boids;
}

/// <summary>
/// Boidsアルゴリズムに基づいた「群衆移動」コンポーネント。
/// - 分離(Separation): 近すぎる仲間から離れる
/// - 整列(Alignment) : 近くの仲間と向きを揃える
/// - 結合(Cohesion)  : 仲間の中心に近づく
/// の3つを組み合わせて、群れとして自然な動きを作ります。
/// 
/// 移動そのものだけを担当し、攻撃やアニメーションは別コンポーネントに任せる想定です。
/// </summary>
[RequireComponent(typeof(Rigidbody))]
public class BoidFlocking : MonoBehaviour
{
    [Header("基本移動パラメータ")]
    [SerializeField] private float maxSpeed = 5f;          // 最高速度
    [SerializeField] private float maxSteerForce = 5f;     // 1フレームで向きを変えられる最大量
    [SerializeField] private float neighborRadius = 5f;    // 仲間とみなす距離
    [SerializeField] private float separationRadius = 2f;  // 離れたい距離

    [Header("各ルールの重み")]
    [SerializeField] private float separationWeight = 1.5f; // 分離の強さ
    [SerializeField] private float alignmentWeight = 1.0f;  // 整列の強さ
    [SerializeField] private float cohesionWeight = 1.0f;   // 結合の強さ

    [Header("ターゲット(任意)")]
    [Tooltip("プレイヤーなど、群れ全体として大まかに向かいたいターゲット。未指定でもOK。")]
    [SerializeField] private Transform target;
    [SerializeField] private float targetWeight = 0.8f;     // ターゲットへの追従の強さ

    [Header("回転設定")]
    [SerializeField] private bool faceVelocity = true;     // 進行方向に向きを合わせるか
    [SerializeField] private float rotationSpeed = 10f;    // 回転の追従スピード

    private Rigidbody rb;

    // 速度を外部から参照したい場合のためのプロパティ(読み取り専用)
    public Vector3 Velocity => rb != null ? rb.velocity : Vector3.zero;

    private void Awake()
    {
        rb = GetComponent<Rigidbody>();

        // 物理挙動の基本設定。必要に応じてインスペクタで上書きしてもOK。
        rb.useGravity = false;        // 群れAIなので重力は基本オフ
        rb.constraints = RigidbodyConstraints.FreezeRotation; // 回転はスクリプトで制御
    }

    private void OnEnable()
    {
        // シーン内のBoidリストに登録
        BoidFlockingRegistry.Register(this);
    }

    private void OnDisable()
    {
        // シーン内のBoidリストから解除
        BoidFlockingRegistry.Unregister(this);
    }

    private void FixedUpdate()
    {
        // 物理挙動はFixedUpdateで制御するのが基本です
        Vector3 steering = CalculateSteering();
        ApplySteering(steering);
        UpdateRotation();
    }

    /// <summary>
    /// 3つのルール(分離・整列・結合)とターゲット追従を合成して、
    /// 最終的なステアリングベクトルを計算します。
    /// </summary>
    private Vector3 CalculateSteering()
    {
        IReadOnlyList<BoidFlocking> allBoids = BoidFlockingRegistry.AllBoids;

        Vector3 separation = Vector3.zero;
        Vector3 alignment = Vector3.zero;
        Vector3 cohesion = Vector3.zero;

        int neighborCount = 0;
        int separationCount = 0;

        foreach (var other in allBoids)
        {
            // 自分自身はスキップ
            if (other == this) continue;

            Vector3 toOther = other.transform.position - transform.position;
            float distance = toOther.magnitude;

            // neighborRadius以内を「仲間」として扱う
            if (distance <= neighborRadius)
            {
                neighborCount++;

                // 整列: 近くの仲間の速度の平均を取りたい
                alignment += other.Velocity;

                // 結合: 近くの仲間の位置の平均を取りたい
                cohesion += other.transform.position;

                // 分離: 近すぎる仲間からは離れる方向に力を加える
                if (distance > 0f && distance <= separationRadius)
                {
                    // 距離が近いほど強く押し返す(1 / distance)
                    separation += (transform.position - other.transform.position) / distance;
                    separationCount++;
                }
            }
        }

        // 整列ベクトルを正規化
        if (neighborCount > 0)
        {
            // 整列: 近くの仲間の平均速度に合わせる
            alignment /= neighborCount;
            alignment = alignment.normalized;

            // 結合: 近くの仲間の中心位置に向かう
            cohesion /= neighborCount;
            cohesion = (cohesion - transform.position).normalized;
        }

        // 分離ベクトルを正規化
        if (separationCount > 0)
        {
            separation /= separationCount;
            separation = separation.normalized;
        }

        // 各ルールに重みをかけて合成
        Vector3 steering =
            separation * separationWeight +
            alignment * alignmentWeight +
            cohesion * cohesionWeight;

        // 任意のターゲット(プレイヤーなど)が指定されている場合は、そこに向かう力も加える
        if (target != null)
        {
            Vector3 toTarget = (target.position - transform.position).normalized;
            steering += toTarget * targetWeight;
        }

        // 最後に、現在の速度から「どれだけ曲げるか」を計算する
        // 望ましい速度 = 現在位置から見たステアリング方向 * maxSpeed
        if (steering != Vector3.zero)
        {
            Vector3 desiredVelocity = steering.normalized * maxSpeed;
            Vector3 steerForce = desiredVelocity - rb.velocity;

            // フレームごとの最大操舵力を制限して、急激な方向転換を防ぐ
            if (steerForce.magnitude > maxSteerForce)
            {
                steerForce = steerForce.normalized * maxSteerForce;
            }

            return steerForce;
        }

        return Vector3.zero;
    }

    /// <summary>
    /// 計算したステアリングベクトルをRigidbodyに適用します。
    /// </summary>
    private void ApplySteering(Vector3 steering)
    {
        // 加速度として力を加える方式
        rb.AddForce(steering, ForceMode.Acceleration);

        // 速度が最大値を超えないようにクランプ
        if (rb.velocity.magnitude > maxSpeed)
        {
            rb.velocity = rb.velocity.normalized * maxSpeed;
        }
    }

    /// <summary>
    /// 進行方向に見た目の向きを合わせる処理。
    /// </summary>
    private void UpdateRotation()
    {
        if (!faceVelocity) return;

        Vector3 v = rb.velocity;
        v.y = 0f; // 2D平面上で動かしたい場合はYを無視する

        if (v.sqrMagnitude > 0.0001f)
        {
            Quaternion targetRot = Quaternion.LookRotation(v.normalized, Vector3.up);
            transform.rotation = Quaternion.Slerp(
                transform.rotation,
                targetRot,
                rotationSpeed * Time.fixedDeltaTime
            );
        }
    }

    /// <summary>
    /// インスペクタ上でパラメータを調整しやすくするためのガイド。
    /// 値が変わったときに制約をかけるなどの処理を行います。
    /// </summary>
    private void OnValidate()
    {
        // 負の値やゼロになっておかしくなるパラメータをガード
        maxSpeed = Mathf.Max(0.1f, maxSpeed);
        maxSteerForce = Mathf.Max(0.01f, maxSteerForce);
        neighborRadius = Mathf.Max(0.01f, neighborRadius);
        separationRadius = Mathf.Clamp(separationRadius, 0.01f, neighborRadius);

        separationWeight = Mathf.Max(0f, separationWeight);
        alignmentWeight = Mathf.Max(0f, alignmentWeight);
        cohesionWeight = Mathf.Max(0f, cohesionWeight);
        targetWeight = Mathf.Max(0f, targetWeight);
    }
}

使い方の手順

ここでは「複数の敵がプレイヤーを追いかけつつ、群れとして自然に動く」ケースを例に、セットアップ手順①〜④を説明します。

① 敵プレハブにコンポーネントを追加する

  1. 空のGameObjectを作成し、「EnemyBoid」などの名前を付けます。
  2. Rigidbody コンポーネントを追加します。
    – Use Gravity: Off(チェックを外す)
    – Constraints: Rotation X/Y/Z を Freeze(回転はスクリプトで制御)
  3. 上記の BoidFlocking.cs をプロジェクトに保存し、EnemyBoid にアタッチします。
  4. 見た目用に、Mesh(Cube / Capsule / 独自モデルなど)やアニメーションを乗せてもOKです(これらは別コンポーネントに分離しておきましょう)。
  5. 完成したら、このGameObjectをプレハブ化しておきます。

② シーンに複数体を配置する

  1. 作成した敵プレハブをシーン内にドラッグ&ドロップし、10〜50体ほど並べます。
  2. 初期配置はバラバラでもOKですが、ある程度近くに置いた方が群れっぽく見えます。

③ プレイヤー(ターゲット)を設定する

プレイヤーがすでにシーンにある前提で進めます。単純な例として、以下のようなプレイヤー移動コンポーネントを用意しても構いません。


using UnityEngine;

/// <summary>
/// 非常にシンプルなプレイヤー移動例(WASDで平面移動)
/// </summary>
[RequireComponent(typeof(Rigidbody))]
public class SimplePlayerMover : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 6f;
    private Rigidbody rb;

    private void Awake()
    {
        rb = GetComponent<Rigidbody>();
        rb.constraints = RigidbodyConstraints.FreezeRotation;
    }

    private void FixedUpdate()
    {
        float h = Input.GetAxisRaw("Horizontal");
        float v = Input.GetAxisRaw("Vertical");
        Vector3 dir = new Vector3(h, 0f, v).normalized;

        rb.velocity = dir * moveSpeed;
    }
}
  1. プレイヤーのGameObject(Player など)をシーンに配置します。
  2. 各敵の BoidFlocking コンポーネントの Target フィールドに、プレイヤーのTransformをドラッグ&ドロップします。
    – 敵の数が多い場合は、プレハブのデフォルト値として設定しておくと楽です。

④ パラメータを調整して「群れの性格」を作る

再生して、敵たちがプレイヤーを追いかけながら群れとして動くことを確認しましょう。うまく動いたら、次のように値を調整して「群れの性格」を作っていきます。

  • neighborRadius
    値を大きくすると、遠くの仲間も「仲間」と認識して動きが大きくまとまります。
    小さくすると、局所的な小さな群れが生まれやすくなります。
  • separationRadius / separationWeight
    – separationRadius を大きくすると、広い範囲でお互いを避けるようになります。
    – separationWeight を大きくすると、「ぶつかりたくない」意識が強くなり、かなりバラけた動きになります。
  • alignmentWeight
    値を上げると、向きが揃った「隊列っぽい」動きになります。
    下げると、個々がバラバラな方向を向きやすくなります。
  • cohesionWeight
    値を上げると、群れの中心に集まりやすく、塊感が増します。
    下げると、全体として散らばりがちになります。
  • targetWeight
    プレイヤー追従の強さです。
    大きくすると「とにかくプレイヤーへ一直線」、小さくすると「群れとしての動き優先」になります。

例えば、「ゾンビの群れがダラダラ追いかけてくる」ような雰囲気を出したい場合は、

  • maxSpeed: 2〜3
  • separationWeight: 1.0
  • alignmentWeight: 0.3
  • cohesionWeight: 1.5
  • targetWeight: 0.5

といった感じで、やや cohesion を強めにしつつ、alignment は弱めにすると「まとまっているけど整列しすぎない」動きになります。

メリットと応用

BoidFlocking コンポーネントの責務は「群れとしての移動」だけに絞っているので、次のようなメリットがあります。

  • プレハブ管理がシンプル
    敵プレハブには「見た目」「攻撃」「HP」「群れ移動」などのコンポーネントを分けてアタッチできます。
    たとえば「群れない敵」は BoidFlocking を外すだけでOK。巨大なAIクラスの中から条件分岐で切り替える必要がありません。
  • レベルデザインが楽になる
    シーン上に敵をポンポン配置するだけで、それっぽい群れ挙動が自動でついてきます。
    「このステージは敵を固めて怖さを出したい」「ここは少し散らしたい」といった調整も、インスペクタのパラメータをいじるだけで完了します。
  • 他のゲームオブジェクトにも流用しやすい
    敵だけでなく、「群れで飛ぶ鳥」「水槽の魚」「空中を漂うドローン」など、群れとして動かしたいオブジェクトにそのまま使えます。
    見た目やアニメーションは別コンポーネントに任せているので、用途ごとに差し替えやすいです。

さらに、BoidFlocking自体も小さな責務に分割しているので、部分的な改造も簡単です。「この群れだけは特別に制御したい」といった要件にも対応しやすくなります。

改造案:特定エリアから出ないようにする(シンプルなバウンディング)

例えば、「この群れはステージ中央付近からあまり離れてほしくない」という場合、次のようなメソッドを追加して、CalculateSteering() の最後で足し合わせることができます。


/// <summary>
/// 一定の半径から外に出ないように、中心へ戻る力を加える例。
/// (center と radius はインスペクタから設定してもOK)
/// </summary>
[SerializeField] private Vector3 boundsCenter = Vector3.zero;
[SerializeField] private float boundsRadius = 30f;
[SerializeField] private float boundsWeight = 1.0f;

private Vector3 CalculateBoundsSteering()
{
    Vector3 toCenter = boundsCenter - transform.position;
    float distanceFromCenter = toCenter.magnitude;

    if (distanceFromCenter > boundsRadius)
    {
        // 外に出たら中心方向へ戻る力を加える
        return toCenter.normalized * boundsWeight;
    }

    return Vector3.zero;
}

そして CalculateSteering() の最後で、


// ...
Vector3 steering =
    separation * separationWeight +
    alignment * alignmentWeight +
    cohesion * cohesionWeight;

steering += CalculateBoundsSteering(); // ここで追加

// ターゲット追従も加える
if (target != null)
{
    Vector3 toTarget = (target.position - transform.position).normalized;
    steering += toTarget * targetWeight;
}

のように合成してあげれば、ステージの外に散らばりすぎない群れを簡単に作れます。

このように、「群れ移動」という1つの責務に絞ったコンポーネントにしておくと、ちょっとした改造やステージごとの個性付けがとてもやりやすくなります。ぜひ自分のゲームの敵AIや演出用オブジェクトに組み込んで、コンポーネント指向な設計の気持ちよさを体感してみてください。