【Godot 4】BoidFlocking (群衆移動) コンポーネントの作り方

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

脱・初心者!Godot 4 ゲーム開発の「2歩目」

新品価格
¥831から
(2025/12/13 21:39時点)

Godot4ローグライク入門 ~ダンジョン自動生成~

新品価格
¥831から
(2025/12/13 21:44時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Godot で「敵がたくさんいる群れ」を作ろうとすると、けっこう面倒ですよね。典型的には:

  • 敵ごとに専用スクリプトを継承で増やしていく
  • シーン階層のどこかに「群れ管理用のマネージャー」を置いて、そこから各敵を操作する
  • 結果として「敵A専用AI」「敵B専用AI」…とスクリプトが増え、再利用しづらくなる

しかも、Boids(ボイド)アルゴリズムのような群衆移動を入れようとすると、

  • 「分離(Separation)」:近づきすぎない
  • 「整列(Alignment)」:近くの仲間と向きを揃える
  • 「結合(Cohesion)」:仲間のいる中心へ寄っていく

といったルールを、それぞれの敵スクリプトにベタ書きしがちです。これを継承ベースでやると、敵のバリエーションが増えた瞬間に地獄ですね。

そこで今回は「継承ではなくコンポーネントとして」群衆移動を実装できる BoidFlocking コンポーネントを用意しました。
敵でも味方でも、動く足場でも、CharacterBody2DNode2Dポン付け するだけで、分離・整列・結合の挙動を付与できます。

【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))にこのコンポーネントを子として付ける形を想定しています。

使い方の手順


  1. コンポーネントスクリプトを用意する


    上記のコードを res://components/BoidFlocking.gd などに保存します。
    Godot エディタで開くと、クラス名付きスクリプトとして認識されるので、ノードに直接アタッチ可能になります。



  2. 敵シーンにコンポーネントをアタッチ


    例として、2D の敵シーンをこんな感じで作ります:


    Enemy (CharacterBody2D)
    ├── Sprite2D
    ├── CollisionShape2D
    └── BoidFlocking (Node2D) ← このノードに BoidFlocking.gd をアタッチ


    • Enemy 本体は移動と当たり判定を担当(CharacterBody2D

    • BoidFlocking は「群衆移動のロジックだけ」を担当


    継承で EnemyWithFlocking みたいなクラスを増やさずに済むのがポイントですね。



  3. 群れのルート(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 ノード を指すようにします。
    これにより、同じコンテナ配下の敵だけを「仲間」として認識します。


  4. パラメータを調整して好みの群れ挙動にする

    • 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 コンポーネントとして切り出しておくと、ゲームごと・敵ごとに「ちょっとだけルールを足す/変える」改造がやりやすくなります。
継承ツリーをいじる前に、「この挙動はコンポーネントにできないかな?」と考えるクセをつけていきましょう。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

脱・初心者!Godot 4 ゲーム開発の「2歩目」

新品価格
¥831から
(2025/12/13 21:39時点)

Godot4ローグライク入門 ~ダンジョン自動生成~

新品価格
¥831から
(2025/12/13 21:44時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

URLをコピーしました!