Godot 4でシューティング系の仕組みを作るとき、つい「プレイヤーシーンを継承して敵を作る」「敵ごとに弾発射ロジックをコピペする」みたいな構成になりがちですよね。
さらに、_process() の中で入力を見て、そのまま弾をインスタンス化して速度を設定して…と書き始めると、プレイヤーや敵のスクリプトがすぐに肥大化していきます。

Godot標準のチュートリアルでも「プレイヤーに弾発射ロジックを直書き」するパターンが多いので、そのまま真似すると:

  • プレイヤーと敵でほぼ同じ発射コードをコピペする
  • 弾の種類を変えたいときに、複数スクリプトを全部書き換えるハメになる
  • 入力で撃つプレイヤーと、タイマーで撃つタレット敵を別実装にしてしまう

といった「継承&コピペ地獄」にハマりがちです。

そこで今回は、「弾を撃つ」という行動だけを独立したコンポーネントに切り出した ProjectileShooter を用意します。
プレイヤーでも敵でもタレットでも、「撃ちたいノード」にこのコンポーネントをアタッチするだけで、入力 or タイマーで弾を発射できるようにしてしまいましょう。

【Godot 4】入力でも自動でも撃てる!「ProjectileShooter」コンポーネント

このコンポーネントは:

  • 親ノードの位置から、指定した「弾シーン」をインスタンス化して
  • 指定方向・指定速度で飛ばす
  • 入力トリガー or 一定間隔の自動射撃の両方に対応

という「弾発射の共通処理」を一つのスクリプトにまとめたものです。
プレイヤー・敵・動く床(トラップ)など、何にでもポン付けできます。


フルコード:ProjectileShooter.gd


extends Node
class_name ProjectileShooter
##
## ProjectileShooter
## 親ノードの位置から、指定した弾シーンをインスタンス化して発射するコンポーネント。
## - 入力トリガー(InputAction)
## - 自動発射(タイマー)
## の両方に対応します。
##

@export_group("Projectile")
## 発射する弾のシーン(PackedScene)
## Bullet.tscn や Laser.tscn など、KinematicBody2D / Area2D / RigidBody2D 等なんでもOK。
@export var projectile_scene: PackedScene

## 弾を発射する向き(ローカル座標系の基準方向)
## 例: (1, 0) なら右方向、(0, -1) なら上方向。
@export var local_direction: Vector2 = Vector2.RIGHT

## 親ノードの rotation を向きに反映するかどうか。
## true にすると、親が回転した向きに合わせて弾が飛びます。
@export var use_parent_rotation: bool = true

## 弾の初速度(ピクセル/秒想定)。弾シーン側が対応している場合に使われます。
@export var projectile_speed: float = 600.0

## 弾の生成位置オフセット(親ローカル座標)
## 例: 銃口の位置などにずらしたい場合に使います。
@export var spawn_offset: Vector2 = Vector2.ZERO

## 弾を追加する親ノード(null の場合は自分の親の親 or シーンルートなどを自動で探します)
@export var projectile_parent_override: NodePath

@export_group("Input Trigger")
## 入力で発射するかどうか
@export var use_input_trigger: bool = true

## 発射に使う InputMap のアクション名(Project Settings > Input Map で定義)
## 例: "shoot", "fire", "attack" など。
@export var fire_action_name: StringName = &"shoot"

## ボタンを押した瞬間だけ撃つかどうか
## true: is_action_just_pressed()
## false: is_action_pressed() を使い、連射レートで撃ち続ける
@export var fire_on_just_pressed: bool = true

@export_group("Auto Fire")
## 自動発射を有効にするかどうか
@export var use_auto_fire: bool = false

## 自動発射の間隔(秒)
@export_range(0.05, 10.0, 0.01, "or_greater")
var auto_fire_interval: float = 0.5

## シーン開始時にすぐ自動発射を始めるかどうか
@export var auto_fire_on_ready: bool = true

@export_group("Misc")
## 弾を撃つたびに呼ばれるシグナル
signal projectile_fired(projectile: Node)

## 内部タイマー(自動発射や連射レート管理に使用)
var _cooldown: float = 0.0
var _auto_fire_timer: Timer

func _ready() -> void:
	# 自動発射用の Timer をセットアップ
	_auto_fire_timer = Timer.new()
	_auto_fire_timer.one_shot = false
	_auto_fire_timer.wait_time = auto_fire_interval
	_auto_fire_timer.autostart = use_auto_fire and auto_fire_on_ready
	_auto_fire_timer.timeout.connect(_on_auto_fire_timeout)
	add_child(_auto_fire_timer)

func _process(delta: float) -> void:
	# 入力トリガーが無効なら何もしない
	if use_input_trigger:
		_handle_input_fire(delta)

	# 自動発射のインターバルが変更されていたら反映
	if is_instance_valid(_auto_fire_timer):
		if not is_equal_approx(_auto_fire_timer.wait_time, auto_fire_interval):
			_auto_fire_timer.wait_time = auto_fire_interval

func _handle_input_fire(delta: float) -> void:
	if fire_action_name == StringName():
		return

	_cooldown = max(0.0, _cooldown - delta)

	# just_pressed か pressed かで分岐
	if fire_on_just_pressed:
		if Input.is_action_just_pressed(fire_action_name):
			_fire_projectile()
	else:
		# 押しっぱなしで連射したい場合は、auto_fire_interval を連射レートとして利用
		if Input.is_action_pressed(fire_action_name) and _cooldown <= 0.0:
			_fire_projectile()
			_cooldown = auto_fire_interval

func _on_auto_fire_timeout() -> void:
	if use_auto_fire:
		_fire_projectile()

func _fire_projectile() -> void:
	if projectile_scene == null:
		push_warning("ProjectileShooter: projectile_scene が設定されていません。")
		return

	var parent_node := get_parent()
	if parent_node == null:
		push_warning("ProjectileShooter: 親ノードが存在しません。")
		return

	# 弾シーンをインスタンス化
	var projectile := projectile_scene.instantiate()
	if projectile == null:
		push_warning("ProjectileShooter: projectile_scene のインスタンス化に失敗しました。")
		return

	# 追加先のノードを決定
	var spawn_parent: Node = _get_projectile_parent()
	if spawn_parent == null:
		push_warning("ProjectileShooter: 弾を追加する親ノードが見つかりません。")
		return

	# 親の位置 + オフセットを基準に弾の位置を決める
	if parent_node is Node2D and projectile is Node2D:
		var parent_2d := parent_node as Node2D
		var proj_2d := projectile as Node2D

		# 親のグローバル位置+(ローカルオフセットを親の回転に合わせて回転させたもの)
		var offset := spawn_offset
		if use_parent_rotation:
			offset = offset.rotated(parent_2d.global_rotation)

		proj_2d.global_position = parent_2d.global_position + offset

		# 向きの計算
		var dir := local_direction.normalized()
		if use_parent_rotation:
			dir = dir.rotated(parent_2d.global_rotation)

		# 弾側に速度を設定するための、よくあるパターンをサポート
		# - velocity プロパティを持っている(例: CharacterBody2D, RigidBody2D など)
		# - set_velocity(Vector2) メソッドを持っている
		# - direction/speed プロパティを持っている
		var velocity := dir * projectile_speed

		if "velocity" in projectile:
			projectile.velocity = velocity
		elif projectile.has_method("set_velocity"):
			projectile.set_velocity(velocity)
		else:
			# 速度の受け取り方を弾シーンが決めている場合は、そこに合わせて改造しましょう
			# ここでは最低限、向きだけは渡しておきます
			if "direction" in projectile:
				projectile.direction = dir
			if "speed" in projectile:
				projectile.speed = projectile_speed

		# 弾の向き(rotation)も合わせたい場合
		if "rotation" in projectile:
			proj_2d.rotation = dir.angle()

	else:
		# Node2D 以外にも対応したい場合は、ここを改造してください
		# 例: 3D(Node3D)用の処理など
		push_warning("ProjectileShooter: 現在は Node2D 系の親・弾のみをサポートしています。")

	# シーンツリーに追加
	spawn_parent.add_child(projectile)

	# シグナル発火(エフェクトやサウンドを繋げるのに便利)
	emit_signal("projectile_fired", projectile)

func _get_projectile_parent() -> Node:
	# 明示的に親が指定されていればそれを使う
	if projectile_parent_override != NodePath():
		var target := get_node_or_null(projectile_parent_override)
		if target != null:
			return target

	# 指定がなければ、自分の親の親(=1つ上の階層)を優先
	var parent := get_parent()
	if parent != null and parent.get_parent() != null:
		return parent.get_parent()

	# それもなければシーンルート
	return get_tree().current_scene

## --- 公開API -------------------------------------------------------------

## 外部から手動で弾を撃ちたいときに呼び出せるメソッド
func shoot_once() -> void:
	_fire_projectile()

## 自動発射を開始
func start_auto_fire() -> void:
	use_auto_fire = true
	if is_instance_valid(_auto_fire_timer):
		_auto_fire_timer.start()

## 自動発射を停止
func stop_auto_fire() -> void:
	use_auto_fire = false
	if is_instance_valid(_auto_fire_timer):
		_auto_fire_timer.stop()

使い方の手順

ここからは、具体的な使用例を 3 パターン紹介します。

例1:プレイヤーがボタン入力で弾を撃つ

弾シーン(Bullet.tscn)を用意

例として、以下のような簡単な弾を作ります。

Bullet (CharacterBody2D)
├── Sprite2D
└── CollisionShape2D

Bullet.gd(最低限の移動処理):


extends CharacterBody2D
@export var life_time: float = 2.0
func _physics_process(delta: float) -> void:
# ProjectileShooter が velocity を設定してくれる前提
move_and_slide()
life_time -= delta
if life_time <= 0.0:
queue_free()

プレイヤーシーンに ProjectileShooter をアタッチ

プレイヤー側のシーン構成例:

Player (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
└── ProjectileShooter (Node)

ProjectileShooter.gd を ProjectileShooter ノードにアタッチします。

インスペクタでパラメータを設定

Player シーンを選択し、ProjectileShooter ノードを選んで:

projectile_sceneBullet.tscn をドラッグ&ドロップ

local_direction = Vector2.RIGHT(右向きに撃つ)

projectile_speed = 600 くらい

use_input_trigger = ON

fire_action_name = “shoot”(Input Map に追加しておく)

fire_on_just_pressed = ON(ボタンを押した瞬間だけ撃つ)

use_auto_fire = OFF

Input Map の設定

Project Settings > Input Map で shoot を追加し、Space キーなどを割り当てます。

これで、ゲーム中に Space を押すと、プレイヤーの位置から弾が飛ぶようになります。


例2:敵タレットが一定間隔で自動射撃

  1. タレットシーンを作成
    Turret (Node2D)
    ├── Sprite2D
    └── ProjectileShooter (Node)

  2. ProjectileShooter を設定
    • projectile_scene = Bullet.tscn(プレイヤーと同じ弾でもOK)
    • local_direction = Vector2.LEFT(左向きに撃つなど)
    • use_parent_rotation = false(タレットが回転しないならどちらでも可)
    • use_input_trigger = OFF(入力は使わない)
    • use_auto_fire = ON
    • auto_fire_interval = 1.0(1秒ごとに発射)
    • auto_fire_on_ready = ON
  3. シーンに配置するだけ

    これで、Turret シーンをステージにポンポン置くだけで、自動で弾を撃つタレットが量産できます。

    Turret 側には「回転させる処理」だけを書いておき、弾発射は ProjectileShooter に丸投げ、という構成にするとスッキリしますね。

例3:動く床がトラップとして弾をばらまく

「動く床が一定間隔で火の玉を飛ばす」みたいなギミックも、同じコンポーネントでOKです。

MovingPlatform (Node2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── PlatformMover (自作の移動コンポーネント)
 └── ProjectileShooter (Node)
  • use_input_trigger = OFF
  • use_auto_fire = ON
  • auto_fire_interval = 0.7 など
  • spawn_offset で、床の端から出るように位置を調整

床の移動ロジックと弾発射ロジックが完全に分離されるので、「動かないタレット」「動くタレット」「プレイヤー」など、同じ ProjectileShooter を色々なノードに再利用できます。


メリットと応用

この ProjectileShooter コンポーネントを使うと:

  • プレイヤー・敵・ギミックのスクリプトが細くなる

    各キャラは「移動」「HP管理」「AI」など自分の責務に集中でき、

    「弾を撃つ」という共通処理は ProjectileShooter に任せられます。
  • シーン構造が浅く、シンプルになる

    「Player 派生クラス」「EnemyBase 継承クラス」などを増やさず、

    Node2D(または CharacterBody2D)にコンポーネントをペタペタ貼るだけで機能追加できます。
  • 弾の種類変更が一括でできる

    例えば「ステージ2からはホーミング弾にしたい」となったとき、

    ProjectileShooter の projectile_scene を差し替えるだけでOKです。
  • レベルデザイン時の調整がラク

    連射間隔(auto_fire_interval)や速度(projectile_speed)をインスペクタから触れるので、

    実際にプレイしながら「もうちょい連射速く」「もう少し遅く」などの調整がすぐできます。

「継承ベースで PlayerShooter, EnemyShooter, BossShooter…」とクラスを増やすより、
一つの汎用コンポーネントを合成(Composition)していく方が、後からの変更に強い構成になりますね。


改造案:マズルフラッシュや発射音を鳴らす

発射のたびにエフェクトやサウンドを出したい場合、projectile_fired シグナルを使うか、
コンポーネント内にちょっとだけ処理を足すと便利です。

例えば、ProjectileShooter に以下のようなメソッドを追加して:


@export_group("Effects")
@export var muzzle_flash_scene: PackedScene
@export var fire_sound: AudioStream

func _play_fire_effects() -> void:
	# マズルフラッシュ
	if muzzle_flash_scene != null and get_parent() is Node2D:
		var flash := muzzle_flash_scene.instantiate()
		var parent_2d := get_parent() as Node2D
		if flash is Node2D:
			var flash_2d := flash as Node2D
			flash_2d.global_position = parent_2d.global_position + spawn_offset
		_get_projectile_parent().add_child(flash)

	# 発射音
	if fire_sound != null:
		var player := AudioStreamPlayer2D.new()
		player.stream = fire_sound
		player.global_position = (get_parent() as Node2D).global_position
		_get_projectile_parent().add_child(player)
		player.play()

そして _fire_projectile() の最後に _play_fire_effects() を呼べば、
「弾発射コンポーネント」一つで、見た目も音もまとめて管理できるようになります。

こんな感じで、ProjectileShooter をベースに「自分のゲーム用の標準シューティングコンポーネント」を育てていくと、
次のプロジェクトでもそのまま持っていける再利用性の高いコードになっていきますね。