敵に爆弾をくっつけたいとき、素直に実装しようとするとけっこう面倒なんですよね。

  • プレイヤー弾を Area2D で作って
  • 敵ごとに「爆弾を受け取る処理」を書いて
  • 場合によっては敵クラスを継承して「StickyEnemy」とか増えていく…

さらに「敵の動きに追従して爆弾も一緒に動く」ようにしようとすると、座標の更新処理を毎フレーム書いたり、「爆弾の親子関係」を意識したりと、だんだんスパゲッティ化していきます。

そこで今回の記事では、「当たった敵の子ノードになり、数秒後に爆発する」という機能を、1つのコンポーネントに丸ごと閉じ込めてしまいます。

弾やトラップのシーンにこのコンポーネントをポン付けするだけで、「吸着 → カウントダウン → 爆発」まで完結するようにして、「継承より合成」でスッキリした設計を目指しましょう。

【Godot 4】敵にピタッ!数秒後ドカン!「StickyBomb」コンポーネント

今回作る StickyBomb コンポーネントは、ざっくり以下のような挙動をします。

  1. Area2D などの衝突ノードと組み合わせて、敵にヒットした瞬間を検知
  2. ヒットした敵ノードの子ノードになり、その場に「吸着」
  3. 指定秒数カウントダウンしたら、爆発エフェクトとダメージ処理を実行
  4. 自分自身(爆弾)を自動的に削除

敵側には特別な継承や専用クラスは不要で、「敵ノードに特定のグループ名を付けておく」だけで動作するようにします。


StickyBomb.gd – フルコード


extends Node2D
class_name StickyBomb
## 吸着爆弾コンポーネント
## - 衝突検知用の Area2D などと組み合わせて使用する
## - 敵に当たると敵の子ノードになり、delay_seconds 後に爆発
## - 爆発時にダメージ処理やエフェクトを実行して自壊する

@export_group("基本設定")
@export var delay_seconds: float = 2.0:
	set(value):
		delay_seconds = max(value, 0.0)

## 爆発半径。0 の場合は半径によるダメージ判定を行わない
@export var explosion_radius: float = 64.0

## ダメージ量。敵側のスクリプトが参照して使う想定
@export var damage: int = 20

## 1回だけ吸着するかどうか
## true: 最初に当たった敵にだけ吸着し、その後は無効
## false: 吸着中でも他の敵に当たったら付け替える(特殊な挙動用)
@export var stick_once: bool = true

@export_group("対象フィルタ")
## 「このグループに属するノードだけを敵として扱う」
## 例: "enemies" や "damageable"
@export var target_group_name: String = "enemies"

## 衝突検知に使う Area2D のパス
## 空の場合は、子ノードから自動で Area2D を探す
@export var hit_area_path: NodePath

@export_group("爆発演出")
## 爆発時にインスタンス化するシーン(任意)
## 例: PackedScene に Explosion.tscn を指定
@export var explosion_scene: PackedScene

## 爆発時に再生するサウンド(任意)
@export var explosion_sound: AudioStream

## 爆発の中心を少しだけオフセットしたい場合に使用
@export var explosion_offset: Vector2 = Vector2.ZERO

## 爆発後に自分を削除するまでの遅延。0 なら即削除
@export var queue_free_delay: float = 0.0


## 内部状態
var _is_stuck: bool = false
var _stuck_target: Node2D
var _hit_area: Area2D
var _timer: SceneTreeTimer
var _audio_player: AudioStreamPlayer2D


func _ready() -> void:
	# 衝突検知用 Area2D を取得
	if hit_area_path != NodePath():
		_hit_area = get_node_or_null(hit_area_path)
	else:
		# 明示されていない場合は子ノードから最初の Area2D を探す
		for child in get_children():
			if child is Area2D:
				_hit_area = child
				break

	if _hit_area == null:
		push_warning("StickyBomb: Area2D が見つかりません。hit_area_path を設定してください。")
	else:
		# 衝突シグナルを接続
		_hit_area.body_entered.connect(_on_body_entered)
		_hit_area.area_entered.connect(_on_area_entered)

	# 爆発サウンド用の AudioStreamPlayer2D を内部で用意(必要なときだけ使う)
	_audio_player = AudioStreamPlayer2D.new()
	add_child(_audio_player)
	_audio_player.bus = "SFX"  # プロジェクトに合わせて変更してください


func _on_body_entered(body: Node) -> void:
	# RigidBody2D / CharacterBody2D / Node2D など、物理ボディを想定
	if not body is Node2D:
		return
	_handle_hit(body)


func _on_area_entered(area: Area2D) -> void:
	# Area2D を敵として扱いたいケースにも対応
	if not area is Node2D:
		return
	_handle_hit(area)


func _handle_hit(target: Node2D) -> void:
	# すでに吸着済みで stick_once が true の場合は無視
	if _is_stuck and stick_once:
		return

	# 指定グループに属していないなら無視
	if target_group_name != "" and not target.is_in_group(target_group_name):
		return

	# ここで吸着処理
	_stick_to_target(target)


func _stick_to_target(target: Node2D) -> void:
	_is_stuck = true
	_stuck_target = target

	# 親ノードを対象に付け替える
	# グローバル座標を維持するため、一時的に座標を退避
	var global_pos := global_position
	var global_rot := global_rotation

	# すでに親がある場合も、強制的に付け替える
	reparent(target)

	# グローバル座標を復元(親変更でローカル座標が変わるため)
	global_position = global_pos
	global_rotation = global_rot

	# タイマーを開始
	if delay_seconds > 0.0:
		_timer = get_tree().create_timer(delay_seconds)
		_timer.timeout.connect(_explode)
	else:
		_explode()


func _explode() -> void:
	# すでに削除されているなどで二重実行を防ぐ
	if not is_inside_tree():
		return

	# 爆発エフェクトを生成
	var explosion_center := global_position + explosion_offset

	if explosion_scene:
		var explosion_instance := explosion_scene.instantiate()
		if explosion_instance is Node2D:
			get_tree().current_scene.add_child(explosion_instance)
			explosion_instance.global_position = explosion_center
		else:
			# Node3D など別タイプの場合はそのままルートに追加
			get_tree().current_scene.add_child(explosion_instance)

	# サウンド再生
	if explosion_sound:
		_audio_player.stream = explosion_sound
		_audio_player.global_position = explosion_center
		_audio_player.play()

	# ダメージ処理(シンプルな範囲ダメージ例)
	# - explosion_radius > 0 のときだけ実行
	# - 対象グループに属するノードを走査し、距離が範囲内ならダメージ
	if explosion_radius > 0 and target_group_name != "":
		_apply_area_damage(explosion_center)

	# 自分を削除
	if queue_free_delay > 0.0:
		var q_timer := get_tree().create_timer(queue_free_delay)
		q_timer.timeout.connect(queue_free)
	else:
		queue_free()


func _apply_area_damage(center: Vector2) -> void:
	# 対象グループに属するノードをすべて取得
	var targets := get_tree().get_nodes_in_group(target_group_name)

	for t in targets:
		if not t is Node2D:
			continue

		var dist := center.distance_to(t.global_position)
		if dist <= explosion_radius:
			# 対象が「damage(amount: int)」メソッドを持っていれば呼び出す
			# もしくは「apply_damage(amount: int)」など、自分のプロジェクトに合わせて変更
			if t.has_method("damage"):
				t.damage(damage)
			elif t.has_method("apply_damage"):
				t.apply_damage(damage)

使い方の手順

例として、プレイヤーが撃つ「吸着爆弾弾」を作って、敵にくっついて爆発させてみましょう。

シーン構成例

プレイヤー弾(StickyBombBullet)シーンをこんな構成にします:

StickyBombBullet (Node2D)
 ├── Sprite2D
 ├── Area2D
 │    └── CollisionShape2D
 └── StickyBomb (Node2D)  ← このコンポーネント

敵側のシーンは例えばこんな感じ:

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── (任意のコンポーネント...)

さらに、敵にはグループ "enemies" を付けておきます。

  • エディタで Enemy シーンを開く
  • 右側の「ノード」タブ → 「グループ」 → enemies を追加

手順① StickyBomb コンポーネントを弾シーンに追加

  1. 上記の StickyBomb.gd をプロジェクトに保存(例: res://components/StickyBomb.gd
  2. StickyBombBullet シーンを開き、ルートの Node2D の子として Node2D を追加
  3. その Node2D にスクリプトとして StickyBomb.gd をアタッチ

シーン構成図(再掲):

StickyBombBullet (Node2D)
 ├── Sprite2D
 ├── Area2D
 │    └── CollisionShape2D
 └── StickyBomb (Node2D)

手順② StickyBomb のパラメータを設定

  • delay_seconds: 例えば 2.0(2秒後に爆発)
  • explosion_radius: 例えば 96(半径96px以内の敵にダメージ)
  • damage: 例えば 30
  • target_group_name: "enemies"(敵グループ名)
  • hit_area_path: 子の Area2D を指定(ドラッグ&ドロップ)
  • explosion_scene: 爆発エフェクトシーン(任意)
  • explosion_sound: 爆発音(任意)

ここまで設定すると、弾が敵に触れた瞬間にその敵の子ノードになり、2秒後に爆発して範囲ダメージという挙動になります。

手順③ 敵側のダメージ処理を用意

StickyBomb は「敵が damage(amount)apply_damage(amount) を持っていれば呼び出す」仕様にしてあります。

敵側のスクリプト例:


extends CharacterBody2D

var hp: int = 100

func damage(amount: int) -> void:
	hp -= amount
	print("Enemy took ", amount, " damage. HP = ", hp)
	if hp <= 0:
		die()

func die() -> void:
	queue_free()

このようにしておけば、爆発時に範囲内にいる全ての enemies グループのノードに対して damage() が呼ばれます。

手順④ プレイヤーや発射装置から弾を撃つ

最後に、プレイヤーなどから StickyBombBullet をインスタンス化して飛ばしましょう。


# Player.gd の一例
@export var sticky_bomb_bullet_scene: PackedScene
@export var bullet_speed: float = 400.0

func shoot_sticky_bomb() -> void:
	if sticky_bomb_bullet_scene == null:
		return

	var bullet := sticky_bomb_bullet_scene.instantiate()
	get_tree().current_scene.add_child(bullet)

	# プレイヤー位置から発射
	bullet.global_position = global_position

	# シンプルに右方向へ飛ばす例(2Dの場合)
	if bullet.has_variable("velocity"):
		# RigidBody2D/CharacterBody2D などに velocity がある場合を想定
		bullet.velocity = Vector2.RIGHT * bullet_speed
	elif bullet.has_method("set_linear_velocity"):
		bullet.set_linear_velocity(Vector2.RIGHT * bullet_speed)

このように、弾の移動ロジックとは完全に分離された形で「吸着 → 爆発」の挙動をコンポーネント化できるのがポイントです。


メリットと応用

StickyBomb コンポーネントを導入すると、次のようなメリットがあります。

  • 敵クラスを汚さない
    敵側には「グループを付ける」「damage() を実装する」程度で済み、
    「吸着爆弾に対応した敵クラス」をわざわざ作る必要がありません。
  • 弾シーンの再利用性が高い
    通常弾シーンに StickyBomb を足すだけで「吸着弾」バリエーションが作れます。
    逆に、トラップシーンに付ければ「触れると敵にくっつく地雷」などにも流用できます。
  • ノード構造が浅く・シンプル
    爆弾の挙動はすべて StickyBomb の中に閉じ込めているので、
    親ノード(弾やトラップ)は「見た目」と「移動」だけに集中できます。
  • パラメータ駆動でレベルデザインが楽
    delay_seconds, explosion_radius, damage などをエディタから調整するだけで、
    「即爆発する吸着弾」「広範囲・低ダメージ爆弾」「狭範囲・高ダメージ爆弾」などを量産できます。

まさに「継承より合成」のお手本のようなコンポーネントですね。
弾・敵・トラップなど、どのシーンにも同じコンポーネントをポン付けできるのが気持ちいいところです。

改造案:爆発前に点滅させる

最後に、ちょっとした改造案です。爆発直前に爆弾を点滅させて「そろそろ爆発するぞ感」を出してみましょう。

以下の関数を StickyBomb に追加し、_stick_to_target() の最後あたりで _start_blink() を呼ぶようにすると動きます。


## 爆発までの時間に応じて点滅させる簡易演出
func _start_blink() -> void:
	# Sprite2D を子から探す(なければ何もしない)
	var sprite := get_node_or_null("Sprite2D")
	if sprite == null:
		for child in get_children():
			if child is Sprite2D:
				sprite = child
				break
	if sprite == null:
		return

	# 点滅速度(秒)
	var blink_interval := 0.1
	# delay_seconds 全体で何回点滅するか
	var blink_count := int(delay_seconds / blink_interval)

	# コルーチン的に点滅させる
	func _blink() -> void:
		for i in blink_count:
			sprite.visible = not sprite.visible
			await get_tree().create_timer(blink_interval).timeout
		# 最後は見える状態に戻しておく
		sprite.visible = true

	_blink()

このように、StickyBomb コンポーネントに少しずつ演出や挙動を足していくことで、
「吸着爆弾」という1つのゲームギミックを、どのシーンからも簡単に再利用できるようになります。
ぜひ自分のプロジェクト用にカスタマイズしてみてください。