Godot 4でシューティングやアクションゲームを作っていると、「散弾(ショットガンみたいな扇状の弾)」を撃ちたくなる場面って多いですよね。
でも素直に実装しようとすると、プレイヤーや敵キャラのスクリプトの中に
- 弾を何発出すか
- 角度をどうずらすか
- どのシーンをインスタンスするか
- クールタイムや入力処理
…といったロジックが全部入りになって、どんどんスクリプトが肥大化していきます。
さらに「敵の散弾」「プレイヤーの散弾」「動く砲台の散弾」みたいにパターンが増えると、継承でなんとかしようとしても、最終的には if 文だらけの巨大クラスになりがちです。
そこでこの記事では、「散弾を撃つ」という機能だけを切り出した、コンポーネント指向の ShotgunSpawner を用意して、
プレイヤーにも敵にも砲台にも、ポン付けで再利用できる形にしてしまいましょう。
【Godot 4】扇状にバラ撒け!「ShotgunSpawner」コンポーネント
以下のコンポーネントは、
- 指定した弾シーンを
- 指定した発射数・角度で扇状に
- 指定した方向を基準に発射
するための、汎用ショットガン発射コンポーネントです。
プレイヤー・敵・ギミックなど、どのノードにもアタッチできるように、単独の Node として実装します。
フルコード:ShotgunSpawner.gd
extends Node
class_name ShotgunSpawner
## ShotgunSpawner
## 一度に複数の弾を、角度をずらして扇状に発射するコンポーネント。
##
## 親ノード(プレイヤー、敵、砲台など)にアタッチして使います。
## 「発射方向」「弾のシーン」「弾速」「発射数」「扇の角度」などを
## エディタ上から柔軟に調整できるようにしてあります。
@export_group("Bullet Settings")
## 発射する弾のシーン(PackedScene)
## 例: Bullet.tscn(Rigidbody2D / Area2D など)
@export var bullet_scene: PackedScene
## 弾の初速(ピクセル/秒)。弾シーン側で速度を持っている場合は 0 にして無視してもOK。
@export var bullet_speed: float = 500.0
## 発射位置のオフセット(ShotgunSpawner のローカル座標)
## 例えば銃口の位置に合わせたい場合に調整します。
@export var spawn_offset: Vector2 = Vector2.ZERO
@export_group("Spread Settings")
## 一度に発射する弾の数
@export_range(1, 64, 1)
var bullet_count: int = 5
## 扇状の合計角度(度数法)。中心から左右に半分ずつ広がります。
## 例: 60度なら、中心から -30度 ~ +30度 に散弾をばら撒きます。
@export_range(0.0, 360.0, 1.0)
var spread_angle_deg: float = 60.0
## 中心の発射方向(度数法)。0度は右向き、90度は下向き(Godotの座標系)。
## 通常は親ノードの向きから計算して渡すのがおすすめです。
@export var base_angle_deg: float = 0.0
@export_group("Fire Control")
## 発射クールタイム(秒)。0なら連射制限なし。
@export_range(0.0, 10.0, 0.01)
var cooldown: float = 0.2
## ショットガンを自動で撃つかどうか(例: タレットや敵用)
@export var auto_fire: bool = false
## 自動発射時のターゲット方向(世界座標)。
## ターゲットがいない場合は base_angle_deg を使います。
@export var auto_target: Node2D
## デバッグ用に発射方向のラインを表示するかどうか
@export var debug_draw: bool = false
## 内部状態:クールタイムタイマー
var _cooldown_timer: float = 0.0
func _process(delta: float) -> void:
# クールタイムを進める
if _cooldown_timer > 0.0:
_cooldown_timer -= delta
# 自動発射モード
if auto_fire:
# ターゲットが設定されていれば、その方向に向けて撃つ
if auto_target and is_instance_valid(auto_target):
var dir: Vector2 = (auto_target.global_position - global_position).normalized()
base_angle_deg = rad_to_deg(dir.angle())
# 発射を試みる(クールタイムを考慮)
fire()
func can_fire() -> bool:
## 今撃てる状態かどうかを返します。
if cooldown > 0.0 and _cooldown_timer > 0.0:
return false
if not bullet_scene:
push_warning("ShotgunSpawner: bullet_scene が設定されていません。")
return false
return true
func fire(custom_base_angle_deg: float = NAN) -> void:
## 散弾を発射します。
## custom_base_angle_deg を指定すると、その角度を中心に扇状に発射します。
## 指定しない場合は base_angle_deg を使用します。
if not can_fire():
return
var center_angle_deg: float = base_angle_deg
if not is_nan(custom_base_angle_deg):
center_angle_deg = custom_base_angle_deg
# 弾数が1発なら、単純に1方向に撃つだけ
if bullet_count <= 1 or spread_angle_deg == 0.0:
_spawn_single_bullet(center_angle_deg)
else:
_spawn_spread_bullets(center_angle_deg)
# クールタイムをリセット
_cooldown_timer = cooldown
func _spawn_single_bullet(angle_deg: float) -> void:
## 単発弾を一発だけ撃つ
var bullet: Node2D = bullet_scene.instantiate()
# ShotgunSpawner の親と同じツリーに出したいので、ルートではなく親の親に入れる
var parent_for_bullet: Node = get_parent() if get_parent() else get_tree().current_scene
parent_for_bullet.add_child(bullet)
# 発射位置(ShotgunSpawner の位置 + ローカルオフセットを回転させたもの)
var angle_rad: float = deg_to_rad(angle_deg)
var offset_rotated: Vector2 = spawn_offset.rotated(angle_rad)
bullet.global_position = global_position + offset_rotated
# 方向ベクトル
var dir: Vector2 = Vector2.RIGHT.rotated(angle_rad)
# 弾側に速度プロパティがあれば設定を試みる
if bullet.has_variable("velocity"):
bullet.set("velocity", dir * bullet_speed)
elif bullet.has_method("set_velocity"):
bullet.call("set_velocity", dir * bullet_speed)
# RigidBody2D なら初速として linear_velocity を設定
if bullet is RigidBody2D:
(bullet as RigidBody2D).linear_velocity = dir * bullet_speed
# 見た目の向きも発射方向に合わせたい場合
if bullet is Node2D:
(bullet as Node2D).rotation = angle_rad
if debug_draw:
_debug_draw_ray(bullet.global_position, dir)
func _spawn_spread_bullets(center_angle_deg: float) -> void:
## 複数弾を扇状に発射する
var half_spread: float = spread_angle_deg * 0.5
# 弾が2発以上のときの角度ステップ
# 例: spread_angle_deg = 60, bullet_count = 5
# -> -30, -15, 0, 15, 30 のように配置
var step: float = 0.0
if bullet_count > 1:
step = spread_angle_deg / float(bullet_count - 1)
for i in bullet_count:
var t: float = float(i) / float(max(bullet_count - 1, 1))
var angle_offset_deg: float = -half_spread + spread_angle_deg * t
var angle_deg: float = center_angle_deg + angle_offset_deg
_spawn_single_bullet(angle_deg)
func _debug_draw_ray(origin: Vector2, dir: Vector2, length: float = 32.0) -> void:
## シンプルなデバッグ描画(エディタ上では見えません)
# ここでは簡易的に、Line2D を一瞬生成してすぐ消す形にしています。
var line := Line2D.new()
line.width = 1.0
line.default_color = Color.YELLOW
line.points = PackedVector2Array([origin, origin + dir.normalized() * length])
get_tree().current_scene.add_child(line)
# 0.1秒後に削除
line.call_deferred("queue_free")
使い方の手順
ここでは、代表的な3パターンを例に使い方を見ていきます。
- プレイヤーのショットガン攻撃
- 敵の扇状弾幕
- 固定砲台(Turret)の自動散弾
手順①:弾シーン(Bullet.tscn)を用意する
まずは「弾」そのものを表すシーンを作ります。シンプルな例として、Area2D ベースの弾を想定します。
Bullet (Area2D) ├── CollisionShape2D └── Sprite2D
Bullet.gd の中身は最低限こんな感じでOKです(velocity だけ持つシンプルな弾):
extends Area2D
@export var life_time: float = 2.0
var velocity: Vector2 = Vector2.ZERO
func _process(delta: float) -> void:
position += velocity * delta
life_time -= delta
if life_time <= 0.0:
queue_free()
この velocity プロパティに、ShotgunSpawner 側から速度ベクトルを流し込みます。
手順②:プレイヤーに ShotgunSpawner をアタッチ
次にプレイヤーシーンにコンポーネントを追加します。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── ShotgunSpawner (Node)
プレイヤーのスクリプト(Player.gd)は、入力に応じて ShotgunSpawner.fire() を呼ぶだけです。
extends CharacterBody2D
@onready var shotgun: ShotgunSpawner = $ShotgunSpawner
func _process(delta: float) -> void:
# マウスカーソル方向に向けて撃つ例
if Input.is_action_pressed("shoot"):
var mouse_pos: Vector2 = get_global_mouse_position()
var dir: Vector2 = (mouse_pos - global_position).normalized()
var angle_deg: float = rad_to_deg(dir.angle())
shotgun.fire(angle_deg)
ShotgunSpawner ノードのインスペクタ設定例:
- bullet_scene:
res://Bullet.tscn - bullet_speed: 800
- bullet_count: 7
- spread_angle_deg: 45
- cooldown: 0.15
- spawn_offset: (16, 0) など、銃口位置
これで、プレイヤーのスクリプト側は「どの程度ばら撒くか」を一切知らず、
「とにかくこの方向に散弾撃って」とコンポーネントに丸投げできます。
手順③:敵に扇状弾幕を持たせる
敵キャラにも全く同じコンポーネントをアタッチできます。
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── ShotgunSpawner (Node)
敵のスクリプト(Enemy.gd)では、例えばプレイヤー方向に一定間隔で散弾を撃つ処理を簡潔に書けます。
extends CharacterBody2D
@export var player: Node2D
@onready var shotgun: ShotgunSpawner = $ShotgunSpawner
func _process(delta: float) -> void:
if not player or not is_instance_valid(player):
return
var dir: Vector2 = (player.global_position - global_position).normalized()
var angle_deg: float = rad_to_deg(dir.angle())
shotgun.fire(angle_deg)
ShotgunSpawner 側の設定を変えるだけで、
- 弾数 3 + 角度 20° → 軽い三連ショット
- 弾数 15 + 角度 120° → 画面を覆う広範囲弾幕
…といったバリエーションを 敵ごとに簡単に切り替えられます。
手順④:固定砲台(Turret)の自動散弾
最後に、自動で散弾を撃ち続ける固定砲台を作ってみましょう。
Turret (Node2D) ├── Sprite2D └── ShotgunSpawner (Node)
Turret.gd はなんと空でもOKです。ロジックはすべて ShotgunSpawner に任せます。
ShotgunSpawner の設定:
- auto_fire: ON
- bullet_scene:
res://Bullet.tscn - bullet_speed: 600
- bullet_count: 8
- spread_angle_deg: 90
- cooldown: 0.5
- auto_target: プレイヤーノード(任意)
auto_target を設定しておけば、常にプレイヤー方向に向けて扇状に発射してくれます。
設定しなければ base_angle_deg を中心に固定方向へばら撒く「固定砲台」になります。
メリットと応用
ShotgunSpawner コンポーネントを使うことで、
- 「散弾を撃つ」という機能を、プレイヤー・敵・ギミックから完全に切り離せる
- ノード階層はシンプルなまま、コンポーネントを付け外しするだけで挙動を変えられる
- 発射数・角度・クールタイムなどは 継承や条件分岐ではなくインスペクタの数値で調整できる
- 「プレイヤー専用の散弾クラス」「敵専用の散弾クラス」みたいな継承ツリーの肥大化を防げる
といったメリットがあります。
レベルデザインの段階でも、「この敵だけ弾数を増やそう」「この砲台は角度を広げよう」といった変更が、コードを一切触らずにできます。
さらに、弾シーン側も velocity というシンプルなインターフェイスにしておけば、
- まっすぐ進む弾
- 途中で加速する弾
- 波打つ弾
などを、弾シーンだけ差し替えて再利用できます。
「弾の動き」と「弾のばら撒き方」をコンポーネントで分離できるのが気持ちいいところですね。
改造案:ショットガンを「回転させながら」撃つ
例えばボス戦で、「回転しながら散弾をばら撒く」パターンを作りたい場合は、
ShotgunSpawner にこんなメソッドを追加するだけで実現できます。
func spin_fire(spin_speed_deg: float, delta: float) -> void:
## 毎フレーム base_angle_deg を回転させながら散弾を発射する
base_angle_deg = wrapf(base_angle_deg + spin_speed_deg * delta, 0.0, 360.0)
fire()
これをボスの _process から呼べば、
func _process(delta: float) -> void:
$ShotgunSpawner.spin_fire(120.0, delta) # 1秒で120度回転しながら発射
のように、ぐるぐる回転する散弾攻撃がすぐに実装できます。
このように、コンポーネントとして分離しておくと、攻撃パターンのバリエーション追加がとても楽になりますね。
