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);
}
}
使い方の手順
ここでは「複数の敵がプレイヤーを追いかけつつ、群れとして自然に動く」ケースを例に、セットアップ手順①〜④を説明します。
① 敵プレハブにコンポーネントを追加する
- 空のGameObjectを作成し、「EnemyBoid」などの名前を付けます。
- Rigidbody コンポーネントを追加します。
– Use Gravity: Off(チェックを外す)
– Constraints: Rotation X/Y/Z を Freeze(回転はスクリプトで制御) - 上記の
BoidFlocking.csをプロジェクトに保存し、EnemyBoid にアタッチします。 - 見た目用に、Mesh(Cube / Capsule / 独自モデルなど)やアニメーションを乗せてもOKです(これらは別コンポーネントに分離しておきましょう)。
- 完成したら、このGameObjectをプレハブ化しておきます。
② シーンに複数体を配置する
- 作成した敵プレハブをシーン内にドラッグ&ドロップし、10〜50体ほど並べます。
- 初期配置はバラバラでも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;
}
}
- プレイヤーのGameObject(
Playerなど)をシーンに配置します。 - 各敵の 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や演出用オブジェクトに組み込んで、コンポーネント指向な設計の気持ちよさを体感してみてください。
