敵に爆弾をくっつけたいとき、素直に実装しようとするとけっこう面倒なんですよね。
- プレイヤー弾を
Area2Dで作って - 敵ごとに「爆弾を受け取る処理」を書いて
- 場合によっては敵クラスを継承して「StickyEnemy」とか増えていく…
さらに「敵の動きに追従して爆弾も一緒に動く」ようにしようとすると、座標の更新処理を毎フレーム書いたり、「爆弾の親子関係」を意識したりと、だんだんスパゲッティ化していきます。
そこで今回の記事では、「当たった敵の子ノードになり、数秒後に爆発する」という機能を、1つのコンポーネントに丸ごと閉じ込めてしまいます。
弾やトラップのシーンにこのコンポーネントをポン付けするだけで、「吸着 → カウントダウン → 爆発」まで完結するようにして、「継承より合成」でスッキリした設計を目指しましょう。
【Godot 4】敵にピタッ!数秒後ドカン!「StickyBomb」コンポーネント
今回作る StickyBomb コンポーネントは、ざっくり以下のような挙動をします。
Area2Dなどの衝突ノードと組み合わせて、敵にヒットした瞬間を検知- ヒットした敵ノードの子ノードになり、その場に「吸着」
- 指定秒数カウントダウンしたら、爆発エフェクトとダメージ処理を実行
- 自分自身(爆弾)を自動的に削除
敵側には特別な継承や専用クラスは不要で、「敵ノードに特定のグループ名を付けておく」だけで動作するようにします。
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 コンポーネントを弾シーンに追加
- 上記の
StickyBomb.gdをプロジェクトに保存(例:res://components/StickyBomb.gd) StickyBombBulletシーンを開き、ルートのNode2Dの子としてNode2Dを追加- その
Node2DにスクリプトとしてStickyBomb.gdをアタッチ
シーン構成図(再掲):
StickyBombBullet (Node2D) ├── Sprite2D ├── Area2D │ └── CollisionShape2D └── StickyBomb (Node2D)
手順② StickyBomb のパラメータを設定
delay_seconds: 例えば2.0(2秒後に爆発)explosion_radius: 例えば96(半径96px以内の敵にダメージ)damage: 例えば30target_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つのゲームギミックを、どのシーンからも簡単に再利用できるようになります。
ぜひ自分のプロジェクト用にカスタマイズしてみてください。
