敵の湧きポイントを作るとき、ついこんな感じの「継承&子ノードてんこ盛り構成」になりがちですよね。
EnemySpawner (Node2D を継承した独自クラス) ├── Timer ├── Marker2D ├── Area2D(プレイヤー検知) └── さらに子ノード...
さらに「ボス専用の湧き」「トラップから湧く敵」「ステージギミックで湧く敵」など、パターンが増えるたびにシーンやスクリプトを増やしていくと、継承ツリーもシーンツリーもどんどん肥大化していきます。
「このステージだけ湧き間隔変えたい」「この湧きポイントは一度だけ湧かせたい」といった調整も、専用の派生クラスを作ったり、条件分岐だらけの巨大スクリプトになったりしがちです。
そこで今回は、「敵を定期的に召喚する」という機能だけを切り出したコンポーネントとして、Summoner(召喚士)コンポーネントを用意してみましょう。
任意のノードにペタっとアタッチするだけで、「ここから一定間隔で雑魚敵を召喚するポイント」に変身させられます。
プレイヤー、ボス、ギミック、ステージ上のマーカーなど、どこにでも合成(Composition)して使えるようにしていきます。
【Godot 4】どのノードも湧きポイント化!「Summoner」コンポーネント
以下がフルコードです。
Godot 4 / GDScript 2.0(class_name 使用)で、そのままコピペして使えます。
## Summoner.gd
## 任意のノードにアタッチして使う「敵召喚コンポーネント」
## シーンツリーを汚さず、どのノードも「召喚士」に変えるためのスクリプトです。
class_name Summoner
extends Node
## --- 設定パラメータ ---
@export var enemy_scene: PackedScene:
## 召喚する「雑魚敵」のシーン
## 例: res://scenes/enemy/ZakoEnemy.tscn
get:
return enemy_scene
set(value):
enemy_scene = value
@export var spawn_interval: float = 3.0:
## 召喚間隔(秒)
## 0 以下にすると「自動召喚しない」設定として扱います。
get:
return spawn_interval
set(value):
spawn_interval = max(value, 0.0)
@export var spawn_limit: int = 0:
## 最大召喚数
## 0 以下なら「無制限」に召喚し続けます。
get:
return spawn_limit
set(value):
spawn_limit = max(value, 0)
@export var simultaneous_limit: int = 0:
## 同時に存在できる最大敵数
## 0 以下なら「制限なし」
get:
return simultaneous_limit
set(value):
simultaneous_limit = max(value, 0)
@export var auto_start: bool = true:
## シーンが読み込まれたら自動で召喚開始するかどうか
## false にすると、コードから start() を呼ぶまで召喚しません。
get:
return auto_start
set(value):
auto_start = value
@export var spawn_in_global_space: bool = true:
## 召喚位置を「グローバル座標」で扱うかどうか
## true: Summoner のグローバル座標に出現
## false: 親ノードのローカル座標系に出現
get:
return spawn_in_global_space
set(value):
spawn_in_global_space = value
@export var random_offset_radius: float = 0.0:
## 召喚位置をランダムに散らす半径(ピクセル)
## 0 の場合はまったく散らさず、ピンポイントで湧かせます。
get:
return random_offset_radius
set(value):
random_offset_radius = max(value, 0.0)
@export var one_shot: bool = false:
## true にすると「一度だけ召喚して終了」します。
## spawn_limit と似ていますが、こちらは「1回だけ & 自動ストップ」の用途向け。
get:
return one_shot
set(value):
one_shot = value
@export var enabled: bool = true:
## エディタ上から一時的に無効化したいとき用のフラグ
get:
return enabled
set(value):
enabled = value
if not enabled:
_stop_timer()
elif auto_start and is_inside_tree():
_start_timer_if_needed()
## --- 内部状態 ---
var _spawned_count: int = 0 ## これまでに召喚した累計数
var _current_alive: int = 0 ## 現在生存している召喚済み敵の数
var _timer: Timer ## 内部で使う Timer
var _is_running: bool = false ## 現在召喚ループが動作中かどうか
## --- ライフサイクル ---
func _ready() -> void:
## 内部用の Timer を動的に生成します。
_timer = Timer.new()
_timer.one_shot = false
_timer.wait_time = max(spawn_interval, 0.01)
_timer.autostart = false
add_child(_timer)
_timer.timeout.connect(_on_timer_timeout)
## 自動開始
if auto_start and enabled and spawn_interval > 0.0:
start()
## --- パブリック API ---
func start() -> void:
## 召喚ループを開始します。
if not enabled:
return
if spawn_interval <= 0.0:
push_warning("Summoner: spawn_interval <= 0.0 のため start() しても自動召喚されません。")
return
_spawned_count = 0 ## 毎回リセットしたくない場合はここをコメントアウト
_current_alive = 0
_is_running = true
_start_timer_if_needed()
func stop() -> void:
## 召喚ループを停止します(既に召喚された敵はそのまま)。
_is_running = false
_stop_timer()
func force_spawn() -> Node:
## 即座に 1 体召喚します(制限を無視したい場合のための API)。
## 戻り値: 生成された敵ノード(null の可能性あり)
return _do_spawn(true)
func get_spawned_count() -> int:
## これまでに召喚した累計数を返します。
return _spawned_count
func get_current_alive() -> int:
## 現在生存している召喚済み敵の数を返します。
return _current_alive
## --- 内部ロジック ---
func _start_timer_if_needed() -> void:
if not is_inside_tree():
return
if not _timer:
return
if _timer.is_stopped():
_timer.wait_time = max(spawn_interval, 0.01)
_timer.start()
func _stop_timer() -> void:
if _timer and not _timer.is_stopped():
_timer.stop()
func _on_timer_timeout() -> void:
if not _is_running:
return
## 制限チェック
if spawn_limit > 0 and _spawned_count >= spawn_limit:
## 上限に達したので停止
stop()
return
if simultaneous_limit > 0 and _current_alive >= simultaneous_limit:
## 同時存在数の上限に達しているので、今回のタイミングでは湧かせない
return
## 実際に召喚
var spawned := _do_spawn(false)
if spawned and one_shot:
## 一度だけ湧かせるモードなら、1体出したら終了
stop()
func _do_spawn(ignore_limits: bool) -> Node:
if not enemy_scene:
push_warning("Summoner: enemy_scene が設定されていません。")
return null
## ignore_limits == true の場合、spawn_limit / simultaneous_limit を無視して強制召喚
if not ignore_limits:
if spawn_limit > 0 and _spawned_count >= spawn_limit:
return null
if simultaneous_limit > 0 and _current_alive >= simultaneous_limit:
return null
var enemy := enemy_scene.instantiate()
if not enemy:
push_warning("Summoner: enemy_scene.instantiate() に失敗しました。")
return null
## 召喚位置の決定
var spawn_position: Vector2 = Vector2.ZERO
## Summoner の親が 2D 系か 3D 系かに応じて座標を決めたいところですが、
## ここでは 2D 用の Node2D / Marker2D を想定して実装しています。
## 3D 用にしたい場合は Position3D / Node3D を前提にした別バージョンを作るとよいです。
if owner and owner is Node2D:
var owner_2d := owner as Node2D
if spawn_in_global_space:
spawn_position = owner_2d.global_position
else:
spawn_position = owner_2d.position
else:
## owner が Node2D でない場合は、とりあえず (0, 0) に湧かせる
spawn_position = Vector2.ZERO
## ランダムオフセットを付与
if random_offset_radius > 0.0:
var angle := randf() * TAU
var radius := randf() * random_offset_radius
var offset := Vector2(cos(angle), sin(angle)) * radius
spawn_position += offset
## 敵ノードをシーンツリーに追加
## 通常は Summoner の親(=湧きポイントのいるレイヤー)にぶら下げるのが自然です。
var parent_for_enemy: Node = get_parent() if get_parent() else get_tree().current_scene
if not parent_for_enemy:
parent_for_enemy = self ## 最悪、自分の子にしておく
parent_for_enemy.add_child(enemy)
## 2D の敵なら position / global_position を設定
if enemy is Node2D:
var enemy_2d := enemy as Node2D
if spawn_in_global_space:
enemy_2d.global_position = spawn_position
else:
enemy_2d.position = spawn_position
## 敵の死亡を検知して _current_alive を減らすための接続
## - 敵側に 'died' シグナルがあるならそれを使う
## - なければ tree_exited シグナルで雑にカウントする
_current_alive += 1
_spawned_count += 1
if "died" in enemy:
## カスタムシグナル died を持っている場合
enemy.died.connect(_on_enemy_died.bind(enemy), CONNECT_ONE_SHOT)
else:
## ない場合は、ツリーから抜けたタイミングで死亡とみなす
enemy.tree_exited.connect(_on_enemy_tree_exited.bind(enemy), CONNECT_ONE_SHOT)
return enemy
func _on_enemy_died(enemy: Node) -> void:
## 敵が「died」シグナルを発火したときに呼ばれます。
if _current_alive > 0:
_current_alive -= 1
func _on_enemy_tree_exited(enemy: Node) -> void:
## 敵がシーンツリーから抜けたときに呼ばれます。
if _current_alive > 0:
_current_alive -= 1
使い方の手順
ここからは実際の使い方を、具体的な例と一緒に見ていきましょう。
基本の流れは「①シーンを作る → ②Summoner をアタッチ → ③敵シーンを指定 → ④必要に応じてパラメータ調整」です。
手順①:雑魚敵シーンを用意する
まずは召喚される側の「雑魚敵」シーンを作っておきます。例として 2D の敵を想定します。
ZakoEnemy.tscn
└── ZakoEnemy (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── (必要なら) AnimationPlayer
もし「敵が倒れたときに Summoner に通知したい」場合は、敵スクリプトに died シグナルを用意すると、より正確なカウントができます。
## ZakoEnemy.gd
extends CharacterBody2D
signal died ## Summoner が拾ってくれるシグナル
var hp: int = 3
func take_damage(amount: int) -> void:
hp -= amount
if hp <= 0:
emit_signal("died")
queue_free()
この died シグナルが無くても、tree_exited でカウントは行うので必須ではありませんが、あった方が精度が高くて気持ちいいですね。
手順②:召喚ポイント側のノードに Summoner をアタッチする
次に、「どこから敵を湧かせたいか」を決めます。例えばステージ上のマーカーや、ボスの子ノードなどです。
例1: ステージ上の固定湧きポイントとして使う場合
Stage1 (Node2D)
├── TileMap
├── PlayerStart (Marker2D)
├── EnemySpawnPoint1 (Marker2D)
│ └── Summoner (Node) <-- このノードに Summoner.gd をアタッチ
└── EnemySpawnPoint2 (Marker2D)
└── Summoner (Node)
例2: ボス本体から雑魚を定期的に呼び出す場合
Boss (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── SummonPoint (Marker2D) │ └── Summoner (Node) <-- ボスの子として雑魚を召喚 └── その他ボス用コンポーネント...
ポイントは、Summoner 自体は普通の Node としてぶら下げるだけということです。
敵を湧かせたい座標は Summoner の owner(通常は親シーンのルート)またはその親の Node2D を基準に計算します。
「継承した EnemySpawner クラスを新しく作る」のではなく、既存のシーンにコンポーネントを後付けするイメージですね。
手順③:Summoner のパラメータを設定する
Summoner ノードを選択すると、インスペクタに以下のようなパラメータが出てきます。
enemy_scene… 召喚する敵シーン(ZakoEnemy.tscnなど)spawn_interval… 召喚間隔(秒)。例: 2.0 なら 2 秒ごとに湧くspawn_limit… 累計何体まで湧かせるか。0 なら無制限simultaneous_limit… 同時に存在できる最大数。0 なら制限なしauto_start… シーン開始時に自動で召喚を始めるかspawn_in_global_space… グローバル座標で湧かせるか(通常は true 推奨)random_offset_radius… 召喚位置をランダムに散らす半径(0 で固定位置)one_shot… true なら 1 回湧かせて終了enabled… 手動で有効 / 無効を切り替えるフラグ
例えば「この湧きポイントは最大 5 体まで、同時に 2 体まで、2 秒おきに湧かせたい」という場合:
enemy_scene=res://scenes/enemy/ZakoEnemy.tscnspawn_interval=2.0spawn_limit=5simultaneous_limit=2auto_start=true
手順④:コードから制御したい場合の例
ゲーム進行に応じて、「今は召喚を止める」「ボスフェーズ2に入ったら召喚を開始する」といった制御をしたいケースも多いですね。
そんなときは、Summoner の start() / stop() / force_spawn() を呼び出せばOKです。
## Boss.gd
extends CharacterBody2D
@onready var summoner: Summoner = $SummonPoint/Summoner
var phase: int = 1
func _ready() -> void:
## フェーズ1では雑魚召喚しない
summoner.stop()
func _process(delta: float) -> void:
if phase == 1 and should_enter_phase2():
phase = 2
## フェーズ2開始と同時に雑魚召喚をスタート
summoner.start()
if phase == 3:
## フェーズ3では一切召喚させたくない
summoner.stop()
func should_enter_phase2() -> bool:
## 体力などの条件で判定する想定
return false
「ちょうど今このタイミングで 1 体だけ湧かせたい」というときは、force_spawn() を使うと、spawn_interval や各種制限を無視して即座に 1 体だけ呼び出せます。
func _on_player_pressed_summon_button() -> void:
## プレイヤーがボタンを押したときにだけ敵を 1 体呼ぶ、など
summoner.force_spawn()
メリットと応用
この Summoner コンポーネントを使うことで、いくつか嬉しいポイントがあります。
- シーン構造がシンプルになる
「EnemySpawnerBase を継承したカスタムノードを大量に作る」必要がなくなり、
既存のシーンにSummonerノードを 1 個追加するだけで湧きポイントを増やせます。 - コンポーネントの再利用性が高い
プレイヤー、ボス、ギミック、マップ上の Marker など、どんなノードにも貼り付けて使えます。
「プレイヤーの周囲に味方を召喚する」「トラップから敵を吐き出す」など、文脈を問わずに再利用できます。 - レベルデザインの自由度が上がる
ステージデザイナーは「ここに Summoner を置いて、敵シーンと間隔だけ設定する」という操作だけで、
さまざまな湧きパターンを試せます。コードをいじらずにバランス調整しやすくなります。 - テストがしやすい
Summoner 単体をテストシーンに置いて、spawn_intervalやspawn_limitを変えながら
「敵がちゃんと湧くか」「上限が効いているか」を確認しやすい構造になっています。
さらに、「継承ではなく合成」で作っているので、同じノードに別のコンポーネント(例: Health、HitFlash、PatrolMover など)をどんどん足していけます。
「ボス本体 + Summoner + HPバー表示 + フェーズ管理コンポーネント」みたいな構成にすると、責務ごとにスッキリ分離された設計になりますね。
改造案:プレイヤーが近づいたら召喚開始する「距離トリガー」
最後に、ちょっとした改造案です。
「プレイヤーが一定距離以内に入ったら Summoner を起動する」ようなトリガーを作ると、よりリッチな湧き方ができます。
以下は、Summoner に依存する小さなコンポーネントの例です。
## SummonerDistanceTrigger.gd
## 一定距離以内にプレイヤーが入ったら Summoner を start() するコンポーネント
class_name SummonerDistanceTrigger
extends Node
@export var summoner: Summoner ## 紐づけたい Summoner
@export var player_path: NodePath ## プレイヤーへのパス
@export var trigger_distance: float = 200.0
@export var one_shot: bool = true ## 一度だけトリガーするかどうか
var _triggered: bool = false
func _process(delta: float) -> void:
if not summoner:
return
if one_shot and _triggered:
return
var player := get_node_or_null(player_path)
if not player or not (player is Node2D):
return
var player_2d := player as Node2D
if owner and owner is Node2D:
var owner_2d := owner as Node2D
var dist := owner_2d.global_position.distance_to(player_2d.global_position)
if dist <= trigger_distance:
summoner.start()
_triggered = true
これをステージに貼り付けると、次のような構成になります。
EnemySpawnPoint1 (Marker2D) ├── Summoner (Node) └── SummonerDistanceTrigger (Node)
Summoner 自体は「召喚すること」に専念し、
「いつ召喚を始めるか」「どんな条件で止めるか」は別コンポーネントに分離することで、責務分離された合成ベースの設計にできます。
こんな感じで、Summoner をベースに自分のゲーム専用の召喚ロジックをどんどん合成していくと、
Godot のシーンツリーもスクリプトもかなりスッキリしてきます。ぜひ、自分のプロジェクト用にカスタマイズしてみてください。
