敵弾を消してくれる「ビットシールド」、アクションやSTGでは定番ギミックですよね。
Godot 4 で素直に実装しようとすると、

  • プレイヤーシーンに「ビット用の子ノード」をたくさんぶら下げる
  • プレイヤー用スクリプトに「ビットの回転処理」「衝突処理」まで全部書いてしまう
  • 敵弾側からプレイヤーを参照して、当たり判定を個別に書く

…といった「肥大化した継承クラス」や「深いノード階層」に陥りがちです。
こうなると、

  • ビットシールドを別キャラに使い回したいときに、コピペ地獄
  • プレイヤーのロジックとビットのロジックがベッタリ結合していてテストしづらい
  • ビットだけ別シーンで動作確認したいのに、親の前提が多すぎて面倒

といった「Godotあるある」なつらみが出てきます。

そこで今回は、

「ただ親にアタッチするだけで、親の周囲をぐるぐる回って敵弾を消してくれる」
コンポーネント指向な OrbitalShield コンポーネントを作ってみましょう。

【Godot 4】ぐるぐる回って弾を消す!「OrbitalShield」コンポーネント

このコンポーネントは、

  • 任意の親ノードの周囲を円軌道で回転する
  • 自分に触れた「敵弾」などのエリア/ボディを検出して削除する
  • パラメータ(半径・回転速度・個数・当たり判定レイヤーなど)をエディタから調整可能

という「ビットシールド」を 1 ノードで完結 させるための汎用コンポーネントです。
プレイヤーであろうが、ボスであろうが、「周囲にビットを回したいノード」にペタッと貼るだけで使えます。


フルコード: OrbitalShield.gd


extends Node2D
class_name OrbitalShield
## 親ノードの周囲を回転するビットシールドコンポーネント。
## - 自身の子として複数のビットスプライトを生成
## - 円軌道で回転させる
## - 指定したレイヤーの敵弾に触れたら QueueFree する

@export_group("Orbit Settings")
## ビットの個数
@export_range(1, 32, 1)
var bit_count: int = 4

## 親の周囲を回る半径(ピクセル)
@export_range(0.0, 1024.0, 1.0)
var radius: float = 48.0

## 回転速度(度/秒)。正で反時計回り、負で時計回り
@export_range(-720.0, 720.0, 1.0)
var angular_speed_deg: float = 90.0

## 初期の開始角度(度)。0 だと右方向からスタート
@export_range(0.0, 360.0, 1.0)
var start_angle_deg: float = 0.0

## ビットを親のどの座標系で回すか
## - true: 親のローカル座標(親に追従)
## - false: ワールド座標(親が動いてもその場で回るような表現に使える)
@export var use_parent_local_space: bool = true


@export_group("Bit Visual")
## ビットの見た目に使うシーン。null の場合は簡易な ColorRect を自動生成
@export var bit_scene: PackedScene

## ビットのスケール
@export var bit_scale: Vector2 = Vector2.ONE

## ビットの色(bit_scene が Sprite 系で Shader を使っている場合などは未使用)
@export var bit_color: Color = Color.WHITE


@export_group("Collision Settings")
## ビットのコリジョン半径(円形)。0 の場合は当たり判定なし
@export_range(0.0, 256.0, 1.0)
var collision_radius: float = 8.0

## ビットのコリジョンが所属するコリジョンレイヤー(ビット側)
@export_range(1, 32, 1)
var bit_collision_layer: int = 1

## ビットが検出するコリジョンマスク(敵弾など)
@export_range(1, 32, 1)
var bit_collision_mask: int = 2

## ヒットした相手を queue_free するかどうか
@export var auto_destroy_target: bool = true

## ヒット時に自分(ビット)も消えるかどうか
@export var destroy_bit_on_hit: bool = false


@export_group("Debug")
## エディタ上で軌道のガイドラインを表示するか
@export var show_orbit_gizmo: bool = true

## ビットの角度をランダムにするか(true なら start_angle は無視)
@export var randomize_start_angle: bool = false


# 内部用: 各ビットの現在角度(ラジアン)
var _bit_angles: Array[float] = []

# 内部用: 乱数
var _rng := RandomNumberGenerator.new()


func _ready() -> void:
    # 親がいない場合は警告して終了
    if get_parent() == null:
        push_warning("OrbitalShield: 親ノードが存在しません。このコンポーネントは何かの子として配置してください。")
        return

    _rng.randomize()
    _create_bits()


func _process(delta: float) -> void:
    if get_parent() == null:
        return

    # 角度を更新
    var angular_speed_rad := deg_to_rad(angular_speed_deg)
    for i in _bit_angles.size():
        _bit_angles[i] += angular_speed_rad * delta

    # 位置を更新
    _update_bits_global_positions()


func _create_bits() -> void:
    # 既存のビットをクリア
    for child in get_children():
        child.queue_free()
    _bit_angles.clear()

    if bit_count <= 0:
        return

    # 各ビットの初期角度を決定
    var base_angle_rad := deg_to_rad(start_angle_deg)
    var angle_step := TAU / float(bit_count)

    for i in bit_count:
        var angle := base_angle_rad + angle_step * i
        if randomize_start_angle:
            angle = _rng.randf_range(0.0, TAU)
        _bit_angles.append(angle)

        var bit_node := _instantiate_bit()
        add_child(bit_node)

        # 最初の位置更新
        var offset := Vector2.RIGHT.rotated(angle) * radius
        if use_parent_local_space:
            bit_node.position = offset
        else:
            bit_node.global_position = get_parent().global_position + offset


func _instantiate_bit() -> Node2D:
    var bit_root: Node2D

    if bit_scene:
        var inst = bit_scene.instantiate()
        if inst is Node2D:
            bit_root = inst
        else:
            # 指定されたシーンが Node2D でない場合はラッパーとして Node2D を作る
            bit_root = Node2D.new()
            bit_root.add_child(inst)
    else:
        # シンプルな見た目のデフォルトビットを自動生成
        bit_root = Node2D.new()
        var rect := ColorRect.new()
        rect.color = bit_color
        rect.size = Vector2(8, 8)
        rect.position = -rect.size / 2.0
        bit_root.add_child(rect)

    bit_root.name = "Bit"

    # 見た目のスケール
    bit_root.scale = bit_scale

    # コリジョンを付与
    if collision_radius > 0.0:
        var area := Area2D.new()
        bit_root.add_child(area)

        area.name = "HitArea2D"
        area.collision_layer = 1 << (bit_collision_layer - 1)
        area.collision_mask = 1 << (bit_collision_mask - 1)

        var shape := CollisionShape2D.new()
        var circle := CircleShape2D.new()
        circle.radius = collision_radius
        shape.shape = circle
        area.add_child(shape)

        # 衝突コールバック
        area.body_entered.connect(_on_bit_hit.bind(area))
        area.area_entered.connect(_on_bit_hit.bind(area))

    return bit_root


func _update_bits_global_positions() -> void:
    var parent_node := get_parent()
    if parent_node == null:
        return

    for i in _bit_angles.size():
        var bit := get_child(i) as Node2D
        if bit == null:
            continue

        var angle := _bit_angles[i]
        var offset := Vector2.RIGHT.rotated(angle) * radius

        if use_parent_local_space:
            # 親のローカル空間で回す場合は、自分(OrbitalShield)を原点とみなす
            bit.position = offset
        else:
            # ワールド空間で回す場合は親の global_position を基準にする
            bit.global_position = parent_node.global_position + offset


func _on_bit_hit(other: Node, owner_area: Area2D) -> void:
    # other: 衝突した相手(敵弾など)
    # owner_area: ヒットを検出した Area2D(どのビットかを特定するのに使える)
    if auto_destroy_target and is_instance_valid(other):
        other.queue_free()

    if destroy_bit_on_hit and is_instance_valid(owner_area):
        var bit_root := owner_area.get_parent()
        if is_instance_valid(bit_root):
            bit_root.queue_free()


func _draw() -> void:
    # エディタ/実行時に軌道を可視化
    if show_orbit_gizmo and radius > 0.0:
        draw_circle(Vector2.ZERO, radius, Color(0.3, 0.8, 1.0, 0.3))


func _notification(what: int) -> void:
    if what == NOTIFICATION_EDITOR_DRAW:
        update()

使い方の手順

ここでは 2D シューティング風のプレイヤーにビットシールドを付ける例で説明します。
他にもボスや動くギミックなど、何にでも同じ手順で付けられます。

手順①: スクリプトを用意する

  1. 上記の OrbitalShield.gd をプロジェクトの res://scripts/ などに保存します。
  2. Godot エディタでスクリプトを開き、問題なくロードできることを確認します。
    class_name OrbitalShield を定義しているので、ノード追加ダイアログから直接追加できるようになります)

手順②: プレイヤーシーンにコンポーネントとして追加

例として、プレイヤーシーンの構成を以下のようにします。

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── OrbitalShield (Node2D)  ← このノードに OrbitalShield.gd をアタッチ
  1. Player シーンを開く
  2. ルートの Player (CharacterBody2D) を選択
  3. 右クリック → 「子ノードを追加」 → 検索欄に「OrbitalShield」と入力
  4. 表示された OrbitalShield を追加

これで「プレイヤーの周囲を回転するビットシールド」コンポーネントが付きました。
OrbitalShield 自体は プレイヤーのロジックとは独立 しているので、プレイヤー側のスクリプトを一切いじらなくていいのがポイントです。

手順③: 敵弾のコリジョンレイヤーを設定する

ビットが何に反応するかは「コリジョンレイヤー」と「マスク」で制御します。
ここでは例として、

  • レイヤー1: プレイヤー
  • レイヤー2: 敵弾

というシンプルな構成にしてみます。

  1. 敵弾シーン(例: EnemyBullet (Area2D))を開く
  2. CollisionShape2D を持った Area2D もしくは RigidBody2D ノードを選択
  3. インスペクタで「コリジョン」カテゴリの
    • Collision Layer に「2」をチェック
    • (必要に応じて)Collision Mask はプレイヤーなどに合わせて設定

OrbitalShield 側では、デフォルトで

  • bit_collision_layer = 1(ビット自身はレイヤー1)
  • bit_collision_mask = 2(レイヤー2=敵弾を検出)

としています。
この組み合わせにより、「ビットはレイヤー2にいるもの(敵弾)にだけ反応し、ヒットしたら queue_free する」動作になります。

手順④: パラメータを調整して動作確認

OrbitalShield ノードを選択し、インスペクタから以下をお好みで設定します。

  • bit_count: ビットの数(例: 4)
  • radius: 回転半径(例: 64)
  • angular_speed_deg: 回転速度(例: 120)
  • bit_scene: ビットの見た目に使うシーン(Sprite2D など)
  • collision_radius: 当たり判定の大きさ(例: 8〜16)
  • auto_destroy_target: ヒットした敵弾を自動で消すか
  • destroy_bit_on_hit: 1 回受けたらビットも消える「消耗型シールド」にするか

ゲームを実行し、敵弾がプレイヤーに近づいたときにビットが弾を消してくれることを確認しましょう。


他の使用例

例1: ボスの周囲を回る破壊可能なオーブ

Boss (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── OrbitalShield (Node2D)
  • bit_scene に「オーブ」用のシーンを指定(HPを持つ敵として実装)
  • bit_collision_mask を「プレイヤー弾のレイヤー」に変更
  • auto_destroy_target = false にして、代わりにオーブ側のスクリプトでダメージ処理

こうすることで、「ボスの周囲にぐるぐる回る破壊可能なパーツ」を簡単に実現できます。
ボス本体のスクリプトはほぼノータッチで済むのがコンポーネント指向のいいところですね。

例2: 動く床の周囲を回るトゲ

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── OrbitalShield (Node2D)
  • bit_scene にトゲのスプライトを指定
  • bit_collision_mask を「プレイヤーのレイヤー」に設定
  • auto_destroy_target = false にして、_on_bit_hit を改造してダメージ処理を行う

床の移動ロジックと「周囲を回るトゲ」のロジックを完全に分離できるので、
「トゲだけ別のギミックに流用する」「床の動きだけ差し替える」といったことが簡単になります。


メリットと応用

この OrbitalShield コンポーネントを使うことで、

  • シーン構造がシンプル:プレイヤーやボスに「ビット管理用の複雑な子ノード階層」を持たせなくてよい
  • ロジックの再利用性が高い:どのノードにも同じコンポーネントを貼るだけでビットシールド化できる
  • テストがしやすい:OrbitalShield 単体のシーンを作って、挙動やパラメータを個別に検証できる
  • 責務が分離される:プレイヤーは「移動/攻撃」、OrbitalShield は「周囲の防御」と役割が明確になる

Godot 標準スタイルだと、どうしても「プレイヤーシーンが全部入り巨大クラス」になりがちですが、
こうやって「防御まわり」は OrbitalShield コンポーネントに丸投げしてしまうと、
継承より合成(Composition)の恩恵をかなり強く感じられるはずです。

改造案: ヒット時にシグナルを飛ばして外部に通知する

「ビットが敵弾を消した回数をスコアに反映したい」「ビットが破壊されたらプレイヤーに知らせたい」など、
外側のノードにイベントを伝えたい場合は、シグナルを追加してみましょう。


signal bit_hit(target: Node, bit_root: Node2D)

func _on_bit_hit(other: Node, owner_area: Area2D) -> void:
    if auto_destroy_target and is_instance_valid(other):
        other.queue_free()

    var bit_root := owner_area.get_parent() as Node2D
    # 外部に通知(プレイヤーやボスが受け取れる)
    bit_hit.emit(other, bit_root)

    if destroy_bit_on_hit and is_instance_valid(bit_root):
        bit_root.queue_free()

あとは、プレイヤー側で


func _ready() -> void:
    var shield := $OrbitalShield
    shield.bit_hit.connect(_on_shield_bit_hit)

func _on_shield_bit_hit(target: Node, bit_root: Node2D) -> void:
    score += 10

のように受け取れば、完全に疎結合なままスコア処理などを追加できます。
ロジックをコンポーネントに閉じ込めつつ、必要な情報だけシグナルで外に出す――
このスタイルに慣れてくると、Godot プロジェクトの保守性がかなり変わってきますよ。