Godot で「敵がたくさんいる群れ」を作ろうとすると、けっこう面倒ですよね。典型的には:
- 敵ごとに専用スクリプトを継承で増やしていく
- シーン階層のどこかに「群れ管理用のマネージャー」を置いて、そこから各敵を操作する
- 結果として「敵A専用AI」「敵B専用AI」…とスクリプトが増え、再利用しづらくなる
しかも、Boids(ボイド)アルゴリズムのような群衆移動を入れようとすると、
- 「分離(Separation)」:近づきすぎない
- 「整列(Alignment)」:近くの仲間と向きを揃える
- 「結合(Cohesion)」:仲間のいる中心へ寄っていく
といったルールを、それぞれの敵スクリプトにベタ書きしがちです。これを継承ベースでやると、敵のバリエーションが増えた瞬間に地獄ですね。
そこで今回は「継承ではなくコンポーネントとして」群衆移動を実装できる BoidFlocking コンポーネントを用意しました。
敵でも味方でも、動く足場でも、CharacterBody2D や Node2D に ポン付け するだけで、分離・整列・結合の挙動を付与できます。
【Godot 4】群れで動けば怖さ倍増!「BoidFlocking」コンポーネント
以下は Godot 4 用の GDScript フルコードです。
「2D前提」で書いていますが、考え方は 3D でもほぼ同じです。
## BoidFlocking.gd
## Boids アルゴリズムによる群衆移動コンポーネント
## 任意の Node2D / CharacterBody2D / RigidBody2D にアタッチして使う想定です。
class_name BoidFlocking
extends Node2D
## --- 基本設定 ---
@export_group("基本設定")
## この Boid が所属する「群れ」のルートノード
## 同じ群れに属する BoidFlocking コンポーネントを子孫に持つノードをまとめるコンテナです。
## 例:Enemies (Node2D) の下に敵シーンを全部ぶら下げておく。
@export var flock_root: NodePath
## Boid が影響を受ける近傍半径(ピクセル)
## この半径内にいる仲間だけを「近くの boid」として扱います。
@export_range(0.0, 2000.0, 1.0) var neighbor_radius: float = 160.0
## 近すぎるときに分離(Separation)を強く働かせる距離
@export_range(0.0, 1000.0, 1.0) var separation_radius: float = 64.0
## 各ルールの重み(どれくらい強く影響するか)
@export_group("ルールの重み")
@export_range(0.0, 10.0, 0.1) var separation_weight: float = 1.5
@export_range(0.0, 10.0, 0.1) var alignment_weight: float = 1.0
@export_range(0.0, 10.0, 0.1) var cohesion_weight: float = 1.0
## 速度制御
@export_group("速度制御")
## 群れとして目指す「基準速度」の大きさ
@export_range(0.0, 2000.0, 1.0) var target_speed: float = 200.0
## 最大速度(これ以上速くならない)
@export_range(0.0, 2000.0, 1.0) var max_speed: float = 300.0
## 回転速度の制限(ラジアン/秒)
@export_range(0.0, 20.0, 0.1) var max_turn_rate: float = 6.0
## ノイズやランダムさをどれくらい混ぜるか
@export_group("ランダム挙動")
@export_range(0.0, 1.0, 0.01) var wander_strength: float = 0.1
## 目標位置(オプション)
## ここに向かうように Cohesion を少しバイアスできます。
@export_group("ターゲット")
@export var use_target_point: bool = false
@export var target_point: Vector2 = Vector2.ZERO
@export_range(0.0, 5.0, 0.1) var target_weight: float = 0.5
## デバッグ描画
@export_group("デバッグ")
@export var debug_draw: bool = false
@export var debug_color_neighbors: Color = Color(0, 1, 0, 0.3)
@export var debug_color_separation: Color = Color(1, 0, 0, 0.3)
## --- 内部状態 ---
var _velocity: Vector2 = Vector2.ZERO
var _random_seed: int = 0
func _ready() -> void:
randomize()
_random_seed = randi()
## 親が CharacterBody2D / RigidBody2D / Node2D など「位置を持つノード」であることを想定
if not (owner is Node2D):
push_warning("BoidFlocking: owner は Node2D 系であることを推奨します。現在: %s" % [owner])
func _process(delta: float) -> void:
if owner == null:
return
var self_node := owner as Node2D
if self_node == null:
return
## 近傍の Boid を収集
var neighbors := _get_neighbors(self_node)
if neighbors.is_empty():
## 近くに仲間がいない場合は、単純に現在の向きに進むだけ
_apply_velocity(self_node, delta)
return
## 3つのルールベクトルを計算
var sep := _compute_separation(self_node, neighbors)
var ali := _compute_alignment(self_node, neighbors)
var coh := _compute_cohesion(self_node, neighbors)
## ランダムな揺らぎ
var wander := _compute_wander()
## 合成
var steering := Vector2.ZERO
steering += sep * separation_weight
steering += ali * alignment_weight
steering += coh * cohesion_weight
steering += wander * wander_strength
## 目標位置へのバイアス
if use_target_point:
var to_target := (target_point - self_node.global_position).normalized()
steering += to_target * target_weight
_update_velocity_and_move(self_node, steering, delta)
func _get_neighbors(self_node: Node2D) -> Array:
var result: Array = []
var root_node: Node = get_node_or_null(flock_root) if flock_root != NodePath("") else get_parent()
if root_node == null:
return result
for child in root_node.get_children():
if child == owner:
continue
## 子(敵シーン)の中に BoidFlocking が付いているケースも考慮
var boid: BoidFlocking = null
if child == owner:
continue
if child is Node:
boid = child.get_node_or_null(get_path().get_file())
## シンプルに「子孫の BoidFlocking を全部拾う」ほうが安全
for descendant in child.get_children():
if descendant == self:
continue
## 上記は複雑になりやすいので、実運用では「flock_root の直下に Boid を持つノードを並べる」前提にします。
## ここではその前提でシンプルに書き直します。
result.clear()
for node in root_node.get_children():
if node == owner:
continue
if node is Node2D:
## 同じオーナー階層に BoidFlocking がついているか確認
var boid_comp := node.get_node_or_null(self.get_path().get_file())
## 上のやり方だとパス解決がややこしいので、代わりに「全ツリーから BoidFlocking を探す」方式に変更
pass
## --- 実装をシンプルにやり直し ---
result = []
## flock_root 以下を DFS して、BoidFlocking が付いている owner を集める
var stack: Array = []
stack.append(root_node)
while not stack.is_empty():
var n: Node = stack.pop_back()
for c in n.get_children():
stack.append(c)
if c == self:
continue
if c is BoidFlocking:
var other_owner := c.owner as Node2D
if other_owner == null:
continue
if other_owner == self_node:
continue
var dist := self_node.global_position.distance_to(other_owner.global_position)
if dist <= neighbor_radius:
result.append(other_owner)
return result
func _compute_separation(self_node: Node2D, neighbors: Array) -> Vector2:
## 近すぎる仲間から離れるベクトル
var force := Vector2.ZERO
var count := 0
for n in neighbors:
if not (n is Node2D):
continue
var to_self := self_node.global_position - n.global_position
var dist := to_self.length()
if dist > 0.0 and dist < separation_radius:
## 近いほど強く押し返す
force += to_self.normalized() / max(dist, 1.0)
count += 1
if count > 0:
force /= float(count)
return force
func _compute_alignment(self_node: Node2D, neighbors: Array) -> Vector2:
## 仲間の平均的な進行方向に合わせるベクトル
var avg_vel := Vector2.ZERO
var count := 0
for n in neighbors:
if not (n is Node2D):
continue
## それぞれの Node が velocity を持っているとは限らないので、
## 「向き(rotation)」から推定する or 速度を持つコンポーネントから取得する。
## ここでは簡易的に「前フレームの _velocity を共有する」設計にします。
var comp: BoidFlocking = null
for c in n.get_children():
if c is BoidFlocking:
comp = c
break
if comp == null:
continue
avg_vel += comp._velocity
count += 1
if count > 0:
avg_vel /= float(count)
return avg_vel.normalized()
func _compute_cohesion(self_node: Node2D, neighbors: Array) -> Vector2:
## 仲間の「重心」へ向かうベクトル
var center := Vector2.ZERO
var count := 0
for n in neighbors:
if not (n is Node2D):
continue
center += n.global_position
count += 1
if count == 0:
return Vector2.ZERO
center /= float(count)
return (center - self_node.global_position).normalized()
func _compute_wander() -> Vector2:
## 単純なランダムベクトル
var rng := RandomNumberGenerator.new()
rng.seed = _random_seed + int(Time.get_ticks_msec())
var angle := rng.randf_range(-PI, PI)
return Vector2.RIGHT.rotated(angle)
func _update_velocity_and_move(self_node: Node2D, steering: Vector2, delta: float) -> void:
if steering == Vector2.ZERO and _velocity == Vector2.ZERO:
## 初期状態ではランダムな向きを与える
_velocity = Vector2.RIGHT.rotated(randf() * TAU) * target_speed
else:
## いまの速度に steering を足して、向きを少しずつ変える
var desired := (_velocity + steering * target_speed).normalized()
if desired == Vector2.ZERO:
desired = _velocity.normalized()
## 回転速度を制限(急にクルッと向きが変わらないようにする)
var current_angle := _velocity.angle()
var desired_angle := desired.angle()
var angle_diff := wrapf(desired_angle - current_angle, -PI, PI)
angle_diff = clamp(angle_diff, -max_turn_rate * delta, max_turn_rate * delta)
var new_angle := current_angle + angle_diff
var speed := clamp(_velocity.length() + target_speed * delta, 0.0, max_speed)
_velocity = Vector2.RIGHT.rotated(new_angle) * speed
_apply_velocity(self_node, delta)
func _apply_velocity(self_node: Node2D, delta: float) -> void:
if _velocity == Vector2.ZERO:
return
## owner のタイプによって移動方法を変える
if "velocity" in owner and owner is CharacterBody2D:
## CharacterBody2D の場合は velocity を使う
owner.velocity = _velocity
owner.move_and_slide()
elif owner is Node2D:
## 単純な Node2D の場合は自前で位置を更新
self_node.global_position += _velocity * delta
else:
## それ以外はとりあえず transform を直接いじる
self_node.global_position += _velocity * delta
## 見た目の向きを速度の向きに合わせる(任意)
self_node.rotation = _velocity.angle()
func _draw() -> void:
if not debug_draw:
return
draw_circle(Vector2.ZERO, neighbor_radius, debug_color_neighbors)
draw_circle(Vector2.ZERO, separation_radius, debug_color_separation)
func _process_debug_draw() -> void:
if debug_draw:
queue_redraw()
func _process_wrapper(delta: float) -> void:
_process(delta)
_process_debug_draw()
## Godot の _process を上書きして、デバッグ描画も呼ぶ
func _enter_tree() -> void:
set_process(true)
func _exit_tree() -> void:
set_process(false)
※ 上のコードは「flock_root 以下に BoidFlocking を持つノードが複数いる」前提で動きます。
実際には、敵シーンのルート(例:Enemy (CharacterBody2D))にこのコンポーネントを子として付ける形を想定しています。
使い方の手順
コンポーネントスクリプトを用意する
上記のコードを
res://components/BoidFlocking.gdなどに保存します。
Godot エディタで開くと、クラス名付きスクリプトとして認識されるので、ノードに直接アタッチ可能になります。敵シーンにコンポーネントをアタッチ
例として、2D の敵シーンをこんな感じで作ります:
Enemy (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── BoidFlocking (Node2D) ← このノードに BoidFlocking.gd をアタッチ
Enemy本体は移動と当たり判定を担当(CharacterBody2D)BoidFlockingは「群衆移動のロジックだけ」を担当
継承で
EnemyWithFlockingみたいなクラスを増やさずに済むのがポイントですね。群れのルート(flock_root)を設定
敵をまとめるコンテナシーンを用意します:
Enemies (Node2D)
├── Enemy1 (CharacterBody2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── BoidFlocking (Node2D)
├── Enemy2 (CharacterBody2D)
│ ├── Sprite2D
│ ├── CollisionShape2D
│ └── BoidFlocking (Node2D)
└── Enemy3 (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── BoidFlocking (Node2D)
各
BoidFlockingノードのインスペクタで、flock_rootに../..などを指定し、共通の Enemies ノード を指すようにします。
これにより、同じコンテナ配下の敵だけを「仲間」として認識します。パラメータを調整して好みの群れ挙動にする
neighbor_radius:大きくすると、より広い範囲の仲間を意識しますseparation_radius:小さくすると、かなり近づいてから避けるようになりますseparation_weight / alignment_weight / cohesion_weight:- 分離を強めると「バラけた群れ」
- 整列を強めると「一方向にスーッと流れる群れ」
- 結合を強めると「ギュッとまとまる群れ」
use_target_pointを ON にしてtarget_pointを設定すると、
群れ全体がその方向へ進みやすくなります(プレイヤー追尾などに応用可能)。
同じコンポーネントは、敵だけでなく例えば「群れで飛ぶ鳥」や「魚群」、「動く足場の群れ」などにもそのまま使い回せます:
Bird (Node2D) ├── Sprite2D └── BoidFlocking (Node2D) Fish (CharacterBody2D) ├── AnimatedSprite2D ├── CollisionShape2D └── BoidFlocking (Node2D)
メリットと応用
このコンポーネント方式の一番のメリットは、シーン構造と責務がスッキリ分離されることです。
- 敵の「見た目・当たり判定・HP管理」と、「群衆移動のロジック」を完全に分けられる
- 「この敵は群れで動かしたい」「この敵は単独AIにしたい」を、BoidFlocking ノードを付けるかどうかで切り替えられる
- シーンツリーを見ただけで「どのノードがどんな挙動を持っているか」が一目瞭然
- 別ゲームでも
BoidFlocking.gdをコピペするだけで、群衆AIを再利用できる
継承ベースで「EnemyBase」→「EnemyBoid」→「EnemyBoidShooter」…とクラスを増やしていくやり方と比べて、コンポーネントをアタッチするだけなので、レベルデザイン時の負担もかなり減ります。
さらに応用として、例えば「特定のエリアに近づいたら分離を強める」「プレイヤーに近いときだけ cohesion を弱める」といった コンテキスト依存の挙動も、BoidFlocking をちょっと拡張するだけで実現できます。
簡単な改造案として、「プレイヤーから一定距離以内にいるときだけ、群れがプレイヤーを追尾する」関数を追加してみましょう:
## BoidFlocking.gd 内に追加する例
@export var player_path: NodePath
@export_range(0.0, 2000.0, 1.0) var chase_radius: float = 400.0
@export_range(0.0, 5.0, 0.1) var chase_weight: float = 1.0
func _compute_player_chase(self_node: Node2D) -> Vector2:
if player_path == NodePath(""):
return Vector2.ZERO
var player := get_node_or_null(player_path) as Node2D
if player == null:
return Vector2.ZERO
var dist := self_node.global_position.distance_to(player.global_position)
if dist > chase_radius:
return Vector2.ZERO
return (player.global_position - self_node.global_position).normalized() * chase_weight
そして _process 内で:
var chase := _compute_player_chase(self_node)
steering += chase
と混ぜてあげれば、「普段は群れでふわふわ動いているけど、プレイヤーが近づくと一斉に襲いかかってくる」ような AI も簡単に作れます。
こんなふうに、BoidFlocking を 1 コンポーネントとして切り出しておくと、ゲームごと・敵ごとに「ちょっとだけルールを足す/変える」改造がやりやすくなります。
継承ツリーをいじる前に、「この挙動はコンポーネントにできないかな?」と考えるクセをつけていきましょう。




