敵の湧きポイントを作るとき、ついこんな感じの「継承&子ノードてんこ盛り構成」になりがちですよね。

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 としてぶら下げるだけということです。
敵を湧かせたい座標は Summonerowner(通常は親シーンのルート)またはその親の 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.tscn
  • spawn_interval = 2.0
  • spawn_limit = 5
  • simultaneous_limit = 2
  • auto_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_intervalspawn_limit を変えながら
    「敵がちゃんと湧くか」「上限が効いているか」を確認しやすい構造になっています。

さらに、「継承ではなく合成」で作っているので、同じノードに別のコンポーネント(例: HealthHitFlashPatrolMover など)をどんどん足していけます。
「ボス本体 + 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 のシーンツリーもスクリプトもかなりスッキリしてきます。ぜひ、自分のプロジェクト用にカスタマイズしてみてください。