弾丸や一時的なエフェクトを作るとき、ついこういうコードを書きがちですよね。

extends CharacterBody2D

var life_time := 1.0

func _process(delta):
    life_time -= delta
    if life_time <= 0.0:
        queue_free()

func _on_body_entered(body):
    queue_free()

これでも動きますが、「弾丸ごとに寿命や自爆条件を書く」「敵もエフェクトも全部同じような処理をコピペする」…と、あっという間にスクリプトが増殖していきます。さらに、

  • 敵の弾とプレイヤーの弾で挙動を変えたい
  • エフェクトは「時間だけで消える」、弾は「時間 or 衝突で消える」

といったバリエーションが出てくると、継承ツリーを増やすか、if だらけの巨大スクリプトになりがちです。

そこで「弾丸の自壊ロジック」は全部コンポーネントに切り出してしまいましょう。SelfDestruct コンポーネントを親ノードにアタッチするだけで、

  • 生成から一定時間後に自動で消える
  • 衝突したタイミングで消える

といった処理を、継承なし・コピペなしで付け外しできるようにします。

【Godot 4】弾もエフェクトも「寿命」はこれ一つ!「SelfDestruct」コンポーネント

コンポーネントの設計方針

  • どのノードにも付けられる汎用コンポーネント(Node継承)
  • 親ノードを破棄する(自分自身ではなく、あくまで「持ち主」を消す)
  • 時間ベース / 衝突ベースの両方に対応
  • 衝突は Area2D / PhysicsBody2D / Area3D / PhysicsBody3D のいずれかに対応
  • Godot 4 の @export で挙動を柔軟に設定

「弾丸の寿命」「敵の断末魔エフェクト」「一時的なバフ表示」など、全部このコンポーネントを付けるだけで完結させるのが狙いです。


SelfDestruct.gd フルコード

extends Node
class_name SelfDestruct
## SelfDestruct コンポーネント
## - 親ノードを一定時間後、または衝突時に自動で queue_free() する
## - 弾丸、エフェクト、一時オブジェクトなどにアタッチして使う

## --- 設定パラメータ ---

@export_category("Lifetime (時間で自爆)")

@export var enable_time_limit: bool = true:
    ## true のとき、生成から time_to_live 秒後に親を削除します。
    ## false にすると「時間では消えない」弾丸などを作れます。
    set(value):
        enable_time_limit = value
        _update_timer_state()

@export_range(0.0, 9999.0, 0.1, "or_greater") var time_to_live: float = 2.0:
    ## 生成から何秒後に自爆するか。
    ## enable_time_limit が true のときのみ有効です。
    set(value):
        time_to_live = max(value, 0.0)
        _update_timer_state()

@export_category("Collision (衝突で自爆)")

@export var enable_collision_destruct: bool = true:
    ## true のとき、衝突した瞬間に親を削除します。
    ## Area2D / PhysicsBody2D / Area3D / PhysicsBody3D に対応します。
    set(value):
        enable_collision_destruct = value
        _update_collision_connections()

@export var one_shot: bool = true:
    ## true: 最初の自爆条件を満たしたら即削除(通常の弾丸向け)
    ## false: 条件を満たしても削除せず、別のトリガーを待つ(特殊な用途向け)
    ## ※通常は true のままでOKです。
    set(value):
        one_shot = value

@export_category("Debug")

@export var print_debug_log: bool = false
    ## true にすると、自爆トリガー時に print でログを出します。


## 内部用: タイマー参照
var _timer: Timer
## 内部用: 衝突シグナル接続済みかどうか
var _collision_connected := false
## 内部用: すでに自爆処理を実行したかどうか
var _has_detonated := false


func _ready() -> void:
    # 親が存在しない場合は意味がないので警告だけ出す
    if get_parent() == null:
        push_warning("SelfDestruct: 親ノードがありません。このコンポーネントは意味を持ちません。")
        return

    _setup_timer()
    _update_timer_state()
    _update_collision_connections()


## --- タイマー関連セットアップ ---

func _setup_timer() -> void:
    # すでに Timer がある場合は再利用
    _timer = get_node_or_null("SelfDestructTimer")
    if _timer == null:
        _timer = Timer.new()
        _timer.name = "SelfDestructTimer"
        _timer.one_shot = true
        add_child(_timer)
    # シグナル接続(重複接続を避ける)
    if not _timer.timeout.is_connected(_on_timer_timeout):
        _timer.timeout.connect(_on_timer_timeout)


func _update_timer_state() -> void:
    if not is_inside_tree():
        return

    if not enable_time_limit:
        if _timer:
            _timer.stop()
        return

    if _timer:
        _timer.wait_time = max(time_to_live, 0.0)
        # ready 後すぐにカウント開始
        if not _timer.is_stopped():
            _timer.stop()
        _timer.start()


func _on_timer_timeout() -> void:
    _trigger_self_destruct("time")


## --- 衝突シグナル関連 ---

func _update_collision_connections() -> void:
    if not is_inside_tree():
        return

    if not enable_collision_destruct:
        _disconnect_collision_signals()
        return

    _connect_collision_signals()


func _connect_collision_signals() -> void:
    if _collision_connected:
        return

    var parent := get_parent()
    if parent == null:
        return

    # 2D 物理ノードの場合
    if parent is Area2D:
        var a2d := parent as Area2D
        if not a2d.body_entered.is_connected(_on_body_or_area_entered):
            a2d.body_entered.connect(_on_body_or_area_entered)
        if not a2d.area_entered.is_connected(_on_body_or_area_entered):
            a2d.area_entered.connect(_on_body_or_area_entered)
        _collision_connected = true
        return

    if parent is PhysicsBody2D:
        var pb2d := parent as PhysicsBody2D
        if not pb2d.body_entered.is_connected(_on_body_or_area_entered):
            pb2d.body_entered.connect(_on_body_or_area_entered)
        if not pb2d.body_shape_entered.is_connected(_on_body_shape_entered):
            pb2d.body_shape_entered.connect(_on_body_shape_entered)
        _collision_connected = true
        return

    # 3D 物理ノードの場合
    if parent is Area3D:
        var a3d := parent as Area3D
        if not a3d.body_entered.is_connected(_on_body_or_area_entered):
            a3d.body_entered.connect(_on_body_or_area_entered)
        if not a3d.area_entered.is_connected(_on_body_or_area_entered):
            a3d.area_entered.connect(_on_body_or_area_entered)
        _collision_connected = true
        return

    if parent is PhysicsBody3D:
        var pb3d := parent as PhysicsBody3D
        if not pb3d.body_entered.is_connected(_on_body_or_area_entered):
            pb3d.body_entered.connect(_on_body_or_area_entered)
        if not pb3d.body_shape_entered.is_connected(_on_body_shape_entered):
            pb3d.body_shape_entered.connect(_on_body_shape_entered)
        _collision_connected = true
        return

    # どの物理ノードでもない場合は警告のみ
    push_warning("SelfDestruct: 親ノードが Area2D / PhysicsBody2D / Area3D / PhysicsBody3D ではありません。衝突自爆は動作しません。")


func _disconnect_collision_signals() -> void:
    if not _collision_connected:
        return

    var parent := get_parent()
    if parent == null:
        return

    if parent is Area2D:
        var a2d := parent as Area2D
        if a2d.body_entered.is_connected(_on_body_or_area_entered):
            a2d.body_entered.disconnect(_on_body_or_area_entered)
        if a2d.area_entered.is_connected(_on_body_or_area_entered):
            a2d.area_entered.disconnect(_on_body_or_area_entered)

    elif parent is PhysicsBody2D:
        var pb2d := parent as PhysicsBody2D
        if pb2d.body_entered.is_connected(_on_body_or_area_entered):
            pb2d.body_entered.disconnect(_on_body_or_area_entered)
        if pb2d.body_shape_entered.is_connected(_on_body_shape_entered):
            pb2d.body_shape_entered.disconnect(_on_body_shape_entered)

    elif parent is Area3D:
        var a3d := parent as Area3D
        if a3d.body_entered.is_connected(_on_body_or_area_entered):
            a3d.body_entered.disconnect(_on_body_or_area_entered)
        if a3d.area_entered.is_connected(_on_body_or_area_entered):
            a3d.area_entered.disconnect(_on_body_or_area_entered)

    elif parent is PhysicsBody3D:
        var pb3d := parent as PhysicsBody3D
        if pb3d.body_entered.is_connected(_on_body_or_area_entered):
            pb3d.body_entered.disconnect(_on_body_or_area_entered)
        if pb3d.body_shape_entered.is_connected(_on_body_shape_entered):
            pb3d.body_shape_entered.disconnect(_on_body_shape_entered)

    _collision_connected = false


func _on_body_or_area_entered(_other: Node) -> void:
    _trigger_self_destruct("collision")


func _on_body_shape_entered(_body_id: int, _body: Node, _body_shape: int, _local_shape: int) -> void:
    _trigger_self_destruct("collision")


## --- 自爆処理本体 ---

func _trigger_self_destruct(reason: String) -> void:
    if _has_detonated and one_shot:
        return

    _has_detonated = true

    if print_debug_log:
        var parent_name := get_parent() != null ? get_parent().name : "<no parent>"
        print("SelfDestruct: parent=%s reason=%s" % [parent_name, reason])

    if not one_shot:
        # one_shot=false の場合、「トリガーが来た」ことだけ記録して終わり。
        # 実際の queue_free() は、外部から呼び出すなどして行う想定です。
        return

    var parent := get_parent()
    if parent != null and parent.is_inside_tree():
        parent.queue_free()

使い方の手順

手順①: スクリプトをプロジェクトに追加

  1. res://components/ など、コンポーネント用フォルダを作成します。
  2. その中に SelfDestruct.gd を作成し、上記コードをコピペして保存します。
  3. Godot エディタを再読み込みすると、SelfDestruct がスクリプトクラスとして認識されます。

手順②: 弾丸シーンにアタッチする

例として、2D のシンプルなプレイヤー弾シーンを考えます。

Bullet (Area2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── SelfDestruct (Node)
  1. 弾丸のルートノードを Area2D で作成します(名前: Bullet)。
  2. Sprite2DCollisionShape2D を子として追加し、見た目と当たり判定を設定します。
  3. 空の Node を追加し、名前を SelfDestruct に変更します。
  4. その Node にスクリプトとして SelfDestruct.gd をアタッチします。

これで、「Bullet の寿命管理」は完全にコンポーネント側に切り離されました。弾丸本体のスクリプトは、移動ロジックだけに集中できます。

手順③: パラメータを調整する

SelfDestruct ノードを選択すると、インスペクタに以下の項目が出ます。

  • enable_time_limit: 生成から一定時間後に消したい場合は ON(デフォルト: ON)
  • time_to_live: 弾丸の寿命(秒)。例: 1.5
  • enable_collision_destruct: 衝突時に消したい場合は ON(デフォルト: ON)
  • one_shot: 最初のトリガーで即削除するなら ON(普通の弾丸は ON でOK)
  • print_debug_log: デバッグ用ログが欲しければ ON

例えば「撃ってから 1 秒で自動消滅、何かに当たったら即消滅」という弾丸なら、

  • enable_time_limit = true
  • time_to_live = 1.0
  • enable_collision_destruct = true
  • one_shot = true

と設定しておけばOKです。

手順④: 他のシーンにもどんどん再利用する

SelfDestruct は「親を消すだけ」のコンポーネントなので、弾丸以外にもガンガン使い回せます。

例1: 敵の死亡エフェクト
DeathEffect (Node2D)
 ├── AnimatedSprite2D
 ├── AudioStreamPlayer2D
 └── SelfDestruct (Node)
  • enable_time_limit = true
  • time_to_live = 0.6(アニメーションの長さに合わせる)
  • enable_collision_destruct = false(当たり判定は不要)

敵が死んだときにこのシーンをインスタンス化するだけで、「勝手に消えるエフェクト」が完成です。

例2: 一時的な動く床(一定時間で消える)
TempPlatform (StaticBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── SelfDestruct (Node)
  • enable_time_limit = true
  • time_to_live = 3.0(3秒で足場が消える)
  • enable_collision_destruct = false(衝突では消えない)

「踏んでから数秒で消える足場」も、プラットフォーム側は何も知らなくてOK。寿命は全部コンポーネント任せです。


メリットと応用

メリット1: 弾丸スクリプトが「移動だけ」になる

SelfDestruct を導入すると、弾丸側はこんなシンプルなコードで済みます。

extends Area2D

@export var speed: float = 600.0
var direction: Vector2 = Vector2.RIGHT

func _physics_process(delta: float) -> void:
    position += direction * speed * delta

寿命・衝突時の破棄は全部コンポーネントに任せるので、弾丸の種類が増えても「移動の違い」だけに集中できます。継承ツリーを分けなくても、コンポーネントの組み合わせで差分を表現できるのがポイントですね。

メリット2: シーン構造がフラットで見通しが良い

Godot 標準の「ノードに直接スクリプトを貼る」スタイルだと、

  • 弾丸のスクリプト内に「移動」「寿命」「衝突処理」「エフェクト生成」などが混在
  • どこに何のロジックが書いてあるか探しづらい

といった状態になりがちです。SelfDestruct をはじめとしたコンポーネントを使うと、

Bullet (Area2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── MoveForward (Component)
 └── SelfDestruct (Component)

のように、「見た目」「当たり判定」「移動」「寿命」がノードレベルで分離されます。継承より合成の考え方で、シーン構造そのものがドキュメントになるイメージですね。

メリット3: レベルデザイナーもパラメータをいじりやすい

寿命や衝突条件が @export で露出しているので、「この弾だけ 0.3 秒で消したい」「このエフェクトは長めに 1.5 秒残したい」といった調整を、コードを書かずにインスペクタから設定できます。

同じ弾丸シーンを複製して、SelfDestruct の time_to_live だけ変えれば、あっという間に「短命弾」「長命弾」などのバリエーションが作れます。


改造案: 衝突相手によって自爆するかをフィルタする

「敵に当たったら消えるけど、味方に当たっても消えない」「壁に当たったときだけ消す」といったフィルタリングをしたい場合は、_trigger_self_destruct の前に条件を挟むのが簡単です。

例えば、「特定のグループに属する相手と衝突したときだけ自爆」する機能を足してみます。

@export var collision_target_group: StringName = &"":
    ## ここにグループ名を入れると、そのグループの相手と衝突したときだけ自爆します。
    ## 空文字列のときは無条件で自爆します。

func _on_body_or_area_entered(other: Node) -> void:
    if collision_target_group != &"":
        if not other.is_in_group(collision_target_group):
            return  # 対象外なので自爆しない
    _trigger_self_destruct("collision")

これで、例えば collision_target_group = "Enemy" としておけば、「敵に当たったときだけ消えるプレイヤー弾」が簡単に作れます。逆に、"Player" を指定すれば「プレイヤーに当たったときだけ消える敵弾」もすぐですね。


SelfDestruct コンポーネントはとてもシンプルですが、「寿命」「破棄」の責務を一箇所に集約できるので、プロジェクトが大きくなるほど恩恵が出てきます。継承より合成の第一歩として、ぜひプロジェクトの標準コンポーネントにしてしまいましょう。