Godotで「ブーメラン」を実装しようとすると、つい専用の Boomerang シーンを作って、そこにプレイヤー用のロジックを全部書きたくなりますよね。
でもそれをやり始めると…

  • プレイヤー用ブーメランと敵用ブーメランで挙動がちょっと違う → シーンやスクリプトが分裂
  • 「戻る相手」を get_parent() 前提で書いてしまい、別のノード構造で使い回しにくい
  • 処理の一部をプレイヤー側に書き、残りを弾側に書き…と責務が分散してカオス

Godot標準の「ノード継承+深いシーン階層」で作り込むと、あとから「敵も同じブーメランを使いたい」「動く床からもブーメランを発射したい」となったときに、かなり面倒になってきます。

そこで今回は、「発射体の移動ロジックだけ」をコンポーネント化して、

  • プレイヤーでも敵でも、どんなノードでも
  • 「戻ってきてほしい相手」を差し替えるだけで
  • ブーメラン挙動を簡単に付け替えられる

そんな 「BoomerangProjectile」コンポーネント を作っていきましょう。
継承ではなく「合成(Composition)」で、ノードにサクッとアタッチして使うスタイルです。


【Godot 4】行って帰ってくる賢い弾!「BoomerangProjectile」コンポーネント

このコンポーネントは、

  • まっすぐ一定距離進む
  • 規定距離 or 時間に達したら「戻りフェーズ」に移行
  • 発射者(親や任意のノード)に向かって速度を反転させて戻ってくる

という挙動を、どんな Node2D / CharacterBody2D / RigidBody2D などにも「コンポーネントとして」付与できるようにしたものです。
移動そのものは velocity を持つボディに任せてもいいし、コンポーネント自身が position を動かしてもOKな、ちょっと柔軟な設計にしています。


フルコード:BoomerangProjectile.gd


extends Node
class_name BoomerangProjectile
## ブーメラン挙動を付与するコンポーネント
##
## - 一定距離 or 時間だけ前進
## - その後、発射者(戻り先)に向かって戻る
## - 任意の Node2D / CharacterBody2D / RigidBody2D にアタッチして使う

@export_group("基本設定")
## 初速の向きと大きさ。たとえば右方向に 400px/s なら Vector2.RIGHT * 400
@export var initial_velocity: Vector2 = Vector2.RIGHT * 400.0

## 進行フェーズ(行き)の最大移動距離。これを超えたら戻りフェーズに移行
@export var max_forward_distance: float = 300.0

## 進行フェーズ(行き)の最大時間(秒)。0以下なら「時間による制限なし」
@export var max_forward_time: float = 0.0

## 戻りフェーズで、戻り先に向かうときの速度(スカラー)。向きは自動計算
@export var return_speed: float = 500.0

@export_group("戻り先設定")
## 戻る相手のノード。未指定の場合は「最初の親(発射者)」を自動的に参照
@export var return_target: Node2D

## 戻り先に十分近づいたとみなす距離(ピクセル)。この距離以内に入ったら
## signal_emitted で通知し、必要なら自ノードを削除するなどの処理を行う。
@export var arrival_threshold: float = 16.0

@export_group("制御オプション")
## true の場合、このコンポーネント自身が position を直接動かす(Node2D前提)
## false の場合、velocity を持つボディ(CharacterBody2D / RigidBody2Dなど)に
## velocity を書き込むだけにして、実際の移動はそのノードに任せる。
@export var move_owner_directly: bool = true

## velocity を書き込む先のプロパティ名。
## 例: "velocity" (CharacterBody2D), "linear_velocity" (RigidBody2D) など。
@export var velocity_property_name: StringName = "velocity"

@export_group("デバッグ")
## デバッグ用: 移動の状態をログに出すかどうか
@export var debug_log: bool = false


signal boomerang_started_returning
## 戻りフェーズに入ったときに発火

signal boomerang_arrived
## 戻り先に到達したときに発火


enum Phase {
    FORWARD,  ## 行き(前進)フェーズ
    RETURN,   ## 戻りフェーズ
}

var _phase: Phase = Phase.FORWARD
var _owner_2d: Node2D
var _start_position: Vector2
var _elapsed_forward_time: float = 0.0


func _ready() -> void:
    # このコンポーネントがくっついている親を Node2D として保持
    _owner_2d = owner as Node2D
    if _owner_2d == null:
        push_warning("BoomerangProjectile: owner is not a Node2D. Movement will not work.")
        return

    _start_position = _owner_2d.global_position
    _elapsed_forward_time = 0.0
    _phase = Phase.FORWARD

    # 戻り先が指定されていなければ、最初は owner の親を戻り先候補にする
    if return_target == null:
        return_target = _owner_2d.get_parent() as Node2D

    # 初速を設定
    _apply_velocity(initial_velocity)

    if debug_log:
        print("BoomerangProjectile: ready, start_position=", _start_position)


func _physics_process(delta: float) -> void:
    if _owner_2d == null:
        return

    match _phase:
        Phase.FORWARD:
            _process_forward(delta)
        Phase.RETURN:
            _process_return(delta)


func _process_forward(delta: float) -> void:
    _elapsed_forward_time += delta

    # 現在の移動距離を計算
    var distance_from_start := _owner_2d.global_position.distance_to(_start_position)

    var distance_limit_reached := distance_from_start >= max_forward_distance
    var time_limit_reached := max_forward_time > 0.0 and _elapsed_forward_time >= max_forward_time

    if distance_limit_reached or time_limit_reached:
        _start_return_phase()


func _process_return(delta: float) -> void:
    if return_target == null:
        # 戻り先が消えていた場合は、何もしない or 自壊するなど方針次第
        if debug_log:
            print("BoomerangProjectile: return_target is null, doing nothing.")
        return

    # 戻り先への方向ベクトル
    var to_target: Vector2 = (return_target.global_position - _owner_2d.global_position)
    var distance_to_target := to_target.length()

    if distance_to_target <= arrival_threshold:
        # 到着判定
        if debug_log:
            print("BoomerangProjectile: arrived at target.")
        emit_signal("boomerang_arrived")
        return  # ここで自ノードを queue_free するかどうかは利用側に任せる

    # 向きだけを正規化して速度を決定
    var direction := to_target.normalized()
    var vel := direction * return_speed
    _apply_velocity(vel)


func _start_return_phase() -> void:
    if _phase == Phase.RETURN:
        return

    _phase = Phase.RETURN
    emit_signal("boomerang_started_returning")

    if debug_log:
        print("BoomerangProjectile: start returning.")

    # 戻りフェーズに入った瞬間に、向きを即座に反転させたい場合はここで設定
    # ただし、毎フレーム _process_return で上書きするので、ここはなくてもよい。
    if return_target != null:
        var dir := (return_target.global_position - _owner_2d.global_position).normalized()
        _apply_velocity(dir * return_speed)


func _apply_velocity(vel: Vector2) -> void:
    ## 実際の移動ロジックをどう扱うかをここで切り替える
    if move_owner_directly:
        # Node2D として直接位置を更新する方式
        # (本当は _physics_process 内で delta を掛けて動かすのが正道だが、
        # ここでは「velocity」をあくまで「1秒あたりの移動量」として扱う想定)
        # → 実際には、owner 側でこの velocity を読んで move_and_slide などに使う方が綺麗。
        #   ここでは「簡易挙動」として直接 position をいじる例を示す。
        if Engine.is_in_physics_frame():
            # 物理フレーム内なら、delta を使って直接動かせるが、
            # このコンポーネントだけで完結させると delta を渡しづらいので、
            # シンプルに「速度をプロパティとして保持」する形に留める案もある。
            pass  # ここでは実際の移動は行わない(下記を参照)
    # velocity を持つボディに値を書き込む方式
    if _owner_2d.has_method("set"):
        # 動的にプロパティを書き込む
        if _owner_2d.has_property(velocity_property_name):
            _owner_2d.set(velocity_property_name, vel)
        else:
            # CharacterBody2D などは has_property が false になることがあるので、
            # 直接 set してしまう(存在しなければエラーになる)
            _owner_2d.set(velocity_property_name, vel)
    else:
        push_warning("BoomerangProjectile: owner does not support setting velocity property '%s'." % velocity_property_name)

※ 上記では「コンポーネントが velocity を書き込む」スタイルにしてあります。
CharacterBody2D にアタッチしている場合は、そのノード側の _physics_processmove_and_slide() を呼ぶ前提です。


使い方の手順

  1. スクリプトを用意する
    上記の BoomerangProjectile.gd をプロジェクト内に保存します。
    例: res://components/BoomerangProjectile.gd
  2. ブーメラン用の弾シーンを作る
    ここでは「プレイヤーが投げるブーメラン弾」を例にします。
    Boomerang (CharacterBody2D)
     ├── Sprite2D
     ├── CollisionShape2D
     └── BoomerangProjectile (Node)
        
    • Boomerang(ルート)に CharacterBody2D を使う
    • BoomerangProjectile ノードに、先ほどのスクリプトをアタッチ
    • BoomerangProjectile.initial_velocity に「右方向に 400」などを設定
    • velocity_property_name"velocity"CharacterBody2Dのプロパティ名)
  3. プレイヤーから発射する
    プレイヤーシーン例:
    Player (CharacterBody2D)
     ├── Sprite2D
     ├── CollisionShape2D
     └── BoomerangSpawnPoint (Marker2D)
        

    プレイヤーのスクリプトから、ブーメランシーンをインスタンス化して発射します。

    
    # Player.gd の一部例
    @export var boomerang_scene: PackedScene
    
    func throw_boomerang() -> void:
        var boomerang := boomerang_scene.instantiate() as CharacterBody2D
        var spawn_point := $BoomerangSpawnPoint
        boomerang.global_position = spawn_point.global_position
    
        # ブーメランのコンポーネントを取得
        var comp := boomerang.get_node("BoomerangProjectile") as BoomerangProjectile
        # 戻り先を自分(Player)に設定
        comp.return_target = self
        # プレイヤーの向きに応じて初速を変えるなど
        var dir := Vector2.RIGHT if is_facing_right() else Vector2.LEFT
        comp.initial_velocity = dir * 400.0
    
        # ルートノード(たとえば現在のシーン)に追加
        get_tree().current_scene.add_child(boomerang)
    
    func _physics_process(delta: float) -> void:
        # プレイヤー自身の移動処理
        velocity = get_input_velocity() * 200.0
        move_and_slide()
    
  4. ブーメラン側の移動処理を仕上げる
    ブーメランルート(CharacterBody2D)にも簡単なスクリプトを付けて、velocity を使って移動させます。
    
    # BoomerangRoot.gd (Boomerangシーンのルート CharacterBody2D 用)
    extends CharacterBody2D
    
    func _physics_process(delta: float) -> void:
        # BoomerangProjectile コンポーネントが velocity を書き換えてくれるので、
        # ここでは単に move_and_slide するだけでOK
        move_and_slide()
    

    これで、BoomerangProjectile コンポーネントが velocity を制御し、
    ルートの CharacterBody2D が実際に移動する、という分業になります。


別の使用例:敵キャラのブーメラン攻撃

同じコンポーネントを、そのまま敵にも使い回せます。

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── BoomerangSpawnPoint (Marker2D)

敵のスクリプト側では、プレイヤーの代わりに Enemy 自身を return_target に設定するだけです。


# Enemy.gd の一部
@export var boomerang_scene: PackedScene

func attack_with_boomerang() -> void:
    var boomerang := boomerang_scene.instantiate() as CharacterBody2D
    boomerang.global_position = $BoomerangSpawnPoint.global_position

    var comp := boomerang.get_node("BoomerangProjectile") as BoomerangProjectile
    comp.return_target = self  # 敵自身に戻ってくる
    comp.initial_velocity = (get_player_direction().normalized() * 350.0)

    get_tree().current_scene.add_child(boomerang)

プレイヤー用と敵用で「別のブーメランシーン」を作る必要はありません。
戻り先だけ差し替えれば、同じコンポーネントで挙動を共有できます。


メリットと応用

メリット

  • 「ブーメランのロジック」が完全に 1 コンポーネントに閉じているので、プレイヤーや敵のスクリプトがスリムになる
  • 戻り先を return_target に差し替えるだけで、どんなノードにもブーメラン挙動を付与できる
  • シーン構造がシンプルなまま:プレイヤーや敵のシーンを継承で増やさない
  • 「深いノード階層にブーメラン用のノードを生やす」必要がなく、
    単に弾シーンにコンポーネントを 1 個付けるだけで済む

応用アイデア

  • 戻りフェーズ中に、ターゲットが動いても追尾し続ける「ホーミング・ブーメラン」にする
  • 戻りきったときに「キャッチ」アニメーションを再生する
  • 戻る途中で敵に当たったら、そこで自壊 or 貫通するなどのヒット処理を追加

たとえば「戻りフェーズに入った瞬間に、トレイルエフェクトを有効化する」ような改造も簡単です。


# BoomerangProjectile.gd に追記する改造案の一例
@export var trail_node_path: NodePath ## トレイル用 Particles2D など

func _start_return_phase() -> void:
    if _phase == Phase.RETURN:
        return

    _phase = Phase.RETURN
    emit_signal("boomerang_started_returning")

    # ★ ここから改造部分: 戻り開始と同時にトレイルをONにする
    if trail_node_path != NodePath():
        var trail := _owner_2d.get_node_or_null(trail_node_path)
        if trail and trail.has_method("set_emitting"):
            trail.set_emitting(true)

    if debug_log:
        print("BoomerangProjectile: start returning.")

    if return_target != null:
        var dir := (return_target.global_position - _owner_2d.global_position).normalized()
        _apply_velocity(dir * return_speed)

このように、「ブーメラン挙動」そのものはコンポーネントに閉じ込めておき、演出やヒット処理は別コンポーネントで足していくと、シーンがどんどん見通し良くなります。
継承で増やすより、コンポーネントを「レゴブロック」みたいに組み合わせる方が、Godot 4 では圧倒的に楽ですね。