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_scene に Bullet.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:敵タレットが一定間隔で自動射撃
- タレットシーンを作成
Turret (Node2D)
├── Sprite2D
└── ProjectileShooter (Node)
- ProjectileShooter を設定
projectile_scene= Bullet.tscn(プレイヤーと同じ弾でもOK)local_direction= Vector2.LEFT(左向きに撃つなど)use_parent_rotation= false(タレットが回転しないならどちらでも可)use_input_trigger= OFF(入力は使わない)use_auto_fire= ONauto_fire_interval= 1.0(1秒ごとに発射)auto_fire_on_ready= ON
- シーンに配置するだけ
これで、Turret シーンをステージにポンポン置くだけで、自動で弾を撃つタレットが量産できます。
Turret 側には「回転させる処理」だけを書いておき、弾発射は ProjectileShooter に丸投げ、という構成にするとスッキリしますね。
例3:動く床がトラップとして弾をばらまく
「動く床が一定間隔で火の玉を飛ばす」みたいなギミックも、同じコンポーネントでOKです。
MovingPlatform (Node2D) ├── Sprite2D ├── CollisionShape2D ├── PlatformMover (自作の移動コンポーネント) └── ProjectileShooter (Node)
use_input_trigger= OFFuse_auto_fire= ONauto_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 をベースに「自分のゲーム用の標準シューティングコンポーネント」を育てていくと、
次のプロジェクトでもそのまま持っていける再利用性の高いコードになっていきますね。
