Godot 4 でシューティング系のプレイヤーや敵を作るとき、Player.gdGun.gd の中に「移動」「入力」「弾発射」「残弾管理」「リロード」などを全部まとめて書いてしまいがちですよね。最初はそれでも動きますが、

  • 「この敵はリロードなし」「この武器は装弾数が多い」「このタレットは自動リロード」…と仕様が増えるたびに if 文だらけになる
  • 武器を増やすときに、毎回コピペして微妙に違う残弾ロジックを書くことになる
  • UI に残弾を表示しようとすると、あちこちから弾数を参照する羽目になる

典型的な「継承+巨大スクリプト」の罠ですね。
そこで今回は、弾数とリロードだけを担当するコンポーネントとして AmmoCounter を切り出してみましょう。

発射処理は「発射できるか?」を AmmoCounter に問い合わせるだけ。
リロード処理も AmmoCounter に丸投げ。
プレイヤーでも敵でもタレットでも、同じコンポーネントをアタッチするだけで、残弾とリロードの挙動を共有できます。

【Godot 4】弾管理はコンポーネントに丸投げ!「AmmoCounter」コンポーネント

以下が、Godot 4 用の AmmoCounter コンポーネントのフルコードです。
ノードタイプは Node なので、どんなシーンにもペタっと貼り付けられます。


## AmmoCounter.gd
## 弾数とリロードを一括管理するコンポーネント
## どんな発射ロジック(レイキャスト、弾シーンインスタンスなど)とも組み合わせ可能

class_name AmmoCounter
extends Node

## === エディタから調整できるパラメータ群 ===============================

@export_category("Ammo Settings")

## マガジン1本あたりの最大弾数
@export var max_ammo: int = 10:
	set(value):
		max_ammo = max(0, value)
		# max_ammo を下げたときに current_ammo がはみ出さないように調整
		if is_inside_tree():
			current_ammo = clamp(current_ammo, 0, max_ammo)

## 所持しているマガジン数(現在装填中のマガジンは含まない)
## -1 にすると「無限リロード可能(弾は有限だがマガジンは無限)」という扱いにできます
@export var extra_magazines: int = 3

## リロードにかかる時間(秒)
@export var reload_time: float = 1.5

## 弾が 0 でもトリガー入力を受け付けるかどうか
## false の場合は is_can_fire() が false を返して一切発射させない
@export var allow_dry_fire: bool = true

## 自動リロードするかどうか(弾が 0 になったら自動でリロード開始)
@export var auto_reload: bool = true

@export_category("Debug / Initial State")

## シーン開始時に自動で弾を満タンにするか
@export var fill_on_ready: bool = true

## デバッグ用:シーン開始時の弾数を固定したい場合に使う
## -1 のときは無視して max_ammo を使う
@export var initial_ammo: int = -1

## === ランタイム状態 ================================================

## 現在のマガジン内の弾数
var current_ammo: int = 0 : set = _set_current_ammo

## リロード中かどうか
var is_reloading: bool = false

## リロード進行度(0.0~1.0)
var reload_progress: float = 0.0

## 内部用タイマー
var _reload_timer: float = 0.0

## === シグナル =======================================================
## UI やエフェクト側から購読できるようにしておきます

## 弾数が変化したときに通知
signal ammo_changed(current: int, max: int)

## リロード開始時に通知
signal reload_started()

## リロード完了時に通知
signal reload_finished()

## リロードできなかったとき(マガジンがない等)
signal reload_failed()

## 空撃ち(弾がないのに撃とうとした)とき
signal dry_fire()

## === ライフサイクル ================================================

func _ready() -> void:
	# 初期弾数の決定
	if fill_on_ready:
		if initial_ammo >= 0:
			current_ammo = clamp(initial_ammo, 0, max_ammo)
		else:
			current_ammo = max_ammo
	emit_signal("ammo_changed", current_ammo, max_ammo)


func _process(delta: float) -> void:
	if is_reloading:
		_reload_timer += delta
		reload_progress = clamp(_reload_timer / reload_time, 0.0, 1.0)

		if _reload_timer >= reload_time:
			_finish_reload()


## === プロパティセッター ============================================

func _set_current_ammo(value: int) -> void:
	current_ammo = clamp(value, 0, max_ammo)
	emit_signal("ammo_changed", current_ammo, max_ammo)

	# 自動リロードが有効で、弾が 0 になったらリロードを開始
	if auto_reload and current_ammo == 0 and not is_reloading:
		_start_reload()


## === 公開 API =======================================================

## 今撃てる状態か?
func is_can_fire() -> bool:
	# リロード中は撃てない
	if is_reloading:
		return false

	# 弾が残っていれば撃てる
	if current_ammo > 0:
		return true

	# 弾が 0 の場合、allow_dry_fire に従う
	if current_ammo == 0 and allow_dry_fire:
		return true

	return false


## 実際に発射したときに呼ぶ関数
## - 戻り値: 実際に弾を消費したかどうか
func consume_ammo() -> bool:
	# 発射できない状態なら何もしない
	if not is_can_fire():
		# 空撃ち扱い
		if current_ammo == 0:
			emit_signal("dry_fire")
		return false

	# 弾がある場合は 1 発分消費
	if current_ammo > 0:
		current_ammo -= 1
		return true

	# allow_dry_fire=true かつ current_ammo == 0 の場合は
	# 見かけ上の発射は許可するが弾は減らない(そもそも 0)
	emit_signal("dry_fire")
	return false


## リロードを開始する
## - 強制リロード(弾が残っていてもリロードしたい)場合は force=true
func start_reload(force: bool = false) -> void:
	# すでにリロード中なら無視
	if is_reloading:
		return

	# マガジンが無い場合はリロード不可
	if extra_magazines == 0:
		emit_signal("reload_failed")
		return

	# 弾が満タンのときは通常リロードしない(force のときだけ許可)
	if current_ammo == max_ammo and not force:
		return

	_start_reload()


## 残弾とマガジンをフルに回復させる(デバッグ・回復アイテム等用)
func refill_all(magazines: int = -1) -> void:
	if magazines >= 0:
		extra_magazines = magazines
	current_ammo = max_ammo
	is_reloading = false
	reload_progress = 0.0
	_reload_timer = 0.0


## 現在の状態をテキストで返す(デバッグ用)
func get_debug_string() -> String:
	var mags_text := extra_magazines < 0 \
		? "∞" \
		: str(extra_magazines)
	return "Ammo: %d / %d | Mags: %s | Reloading: %s (%.2f)" % [
		current_ammo,
		max_ammo,
		mags_text,
		str(is_reloading),
		reload_progress
	]


## === 内部処理 =======================================================

func _start_reload() -> void:
	# マガジンが有限かつ 0 の場合はリロード不可
	if extra_magazines == 0:
		emit_signal("reload_failed")
		return

	is_reloading = true
	reload_progress = 0.0
	_reload_timer = 0.0
	emit_signal("reload_started")


func _finish_reload() -> void:
	is_reloading = false
	reload_progress = 1.0
	_reload_timer = 0.0

	# マガジンを1つ消費(extra_magazines < 0 のときは無限扱い)
	if extra_magazines > 0:
		extra_magazines -= 1

	# マガジンを満タンにする
	current_ammo = max_ammo

	emit_signal("reload_finished")

使い方の手順

例として「プレイヤーがマシンガンを撃つ」シーンを想定します。
プレイヤー、敵、タレット、動く床に乗った砲台…どれにでも同じ手順で付けられます。

① シーン構成に AmmoCounter を追加する

プレイヤーシーンの例:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── Muzzle (Marker2D)        # 発射位置
 └── AmmoCounter (Node)       # ← このノードに AmmoCounter.gd をアタッチ
  1. Node を追加して名前を AmmoCounter にする
  2. そのノードに上記の AmmoCounter.gd をアタッチ
  3. インスペクタで max_ammo, extra_magazines, reload_time などを好みで調整

② プレイヤーの発射スクリプトから AmmoCounter を参照する

プレイヤー側のスクリプト例:


## Player.gd
extends CharacterBody2D

@onready var muzzle: Node2D = $Muzzle
@onready var ammo_counter: AmmoCounter = $AmmoCounter

@export var bullet_scene: PackedScene
@export var fire_interval: float = 0.1

var _fire_cooldown: float = 0.0


func _process(delta: float) -> void:
	_fire_cooldown = max(_fire_cooldown - delta, 0.0)

	# 左クリックで発射
	if Input.is_action_pressed("fire"):
		_try_fire()

	# Rキーでリロード
	if Input.is_action_just_pressed("reload"):
		ammo_counter.start_reload()

	# デバッグ: F3 で状態表示
	if Input.is_action_just_pressed("debug_print"):
		print(ammo_counter.get_debug_string())


func _try_fire() -> void:
	# クールタイム中は撃たない
	if _fire_cooldown > 0.0:
		return

	# AmmoCounter に発射可能かどうかを問い合わせ
	if not ammo_counter.is_can_fire():
		# 発射できない(リロード中 or 弾0&空撃ち禁止)
		return

	# 発射処理(ここは自由に書き換えてOK)
	var bullet := bullet_scene.instantiate()
	get_tree().current_scene.add_child(bullet)
	bullet.global_position = muzzle.global_position
	bullet.global_rotation = muzzle.global_rotation

	# 弾を1発分消費
	var consumed := ammo_counter.consume_ammo()
	if consumed:
		_fire_cooldown = fire_interval

ポイントは、Player.gd は「弾数のことを知らない」という設計にしていることです。
「撃てるか?」→ AmmoCounter に聞く。
「撃ったよ」→ AmmoCounter に報告して弾を減らしてもらう。
それだけです。

③ UI に残弾を表示する

AmmoCounter は ammo_changed シグナルを発行するので、UI 側から簡単に購読できます。

HUD (CanvasLayer)
 └── AmmoLabel (Label)

## HUD.gd
extends CanvasLayer

@export var player_path: NodePath
@onready var ammo_label: Label = $AmmoLabel

var _ammo_counter: AmmoCounter


func _ready() -> void:
	var player := get_node(player_path)
	_ammo_counter = player.get_node("AmmoCounter") as AmmoCounter

	# シグナル接続
	_ammo_counter.ammo_changed.connect(_on_ammo_changed)

	# 初期表示
	_on_ammo_changed(_ammo_counter.current_ammo, _ammo_counter.max_ammo)


func _on_ammo_changed(current: int, max: int) -> void:
	ammo_label.text = "%d / %d" % [current, max]

これで、プレイヤーの残弾が変化するたびに UI が自動で更新されます。
敵タレット用 HUD を作りたいときも、同じ HUD シーンを使い回して player_path をタレットに差し替えるだけで OK ですね。

④ 敵タレットにもそのまま流用する

敵タレットシーンの例:

Turret (Node2D)
 ├── Sprite2D
 ├── Muzzle (Marker2D)
 └── AmmoCounter (Node)

## Turret.gd
extends Node2D

@onready var muzzle: Node2D = $Muzzle
@onready var ammo_counter: AmmoCounter = $AmmoCounter

@export var bullet_scene: PackedScene
@export var fire_interval: float = 0.5

var _fire_cooldown: float = 0.0


func _process(delta: float) -> void:
	_fire_cooldown = max(_fire_cooldown - delta, 0.0)

	# 適当にプレイヤー方向を向いているとして、一定間隔で撃つ
	if _fire_cooldown == 0.0:
		_try_fire()


func _try_fire() -> void:
	if not ammo_counter.is_can_fire():
		return

	var bullet := bullet_scene.instantiate()
	get_tree().current_scene.add_child(bullet)
	bullet.global_position = muzzle.global_position
	bullet.global_rotation = muzzle.global_rotation

	if ammo_counter.consume_ammo():
		_fire_cooldown = fire_interval

プレイヤーとほぼ同じコードで、残弾・リロードロジックは完全に共有できています。
「継承」ではなく「AmmoCounter をアタッチするだけ」で、リロード可能な敵を量産できるのがポイントです。

メリットと応用

  • 巨大な Player.gd / Gun.gd から残弾ロジックが消える
    「移動」「エイム」「発射」「残弾」「UI 更新」がそれぞれ分離されて、読みやすさが一気に上がります。
  • プレイヤー・敵・タレット・ギミックでロジックを完全共有
    残弾・リロードの挙動を変えたいときは AmmoCounter だけ直せば全キャラに反映されます。
  • シーン構造が浅くて済む
    「Weapon ノードの下にさらに Ammo ノードがあって…」という深いツリーを作らず、Node 1個追加で完結します。
  • UI やエフェクトとの連携が楽
    シグナルで通知しているので、マズルフラッシュ、リロードアニメ、サウンドなどを別コンポーネントとして好きなだけぶら下げられます。

応用として、例えば「リロード中はスロー演出を入れる」「最後の1発だけ色を変える」なども簡単です。
最後に、AmmoCounter を少し改造して「マガジンが切れたときに自動で UI に警告を出す」例を示します。


## AmmoCounter.gd 内に追加する改造案
## マガジンが尽きたタイミングで warning シグナルを飛ばす

signal magazines_depleted()  # 追加: マガジンが尽きたとき

func _finish_reload() -> void:
	is_reloading = false
	reload_progress = 1.0
	_reload_timer = 0.0

	# マガジンを1つ消費(extra_magazines < 0 のときは無限扱い)
	if extra_magazines > 0:
		extra_magazines -= 1
		# ここで 0 になったら警告を出す
		if extra_magazines == 0:
			emit_signal("magazines_depleted")

	# マガジンを満タンにする
	current_ammo = max_ammo

	emit_signal("reload_finished")

HUD.gd から magazines_depleted を購読して、画面に「NO MORE MAGAZINES!」と表示する、みたいな演出もコンポーネント間の疎結合を保ったまま実装できます。

「発射ロジック」「残弾管理」「UI 更新」をそれぞれ独立したコンポーネントに分割していくと、Godot プロジェクト全体がかなり見通し良くなります。
ぜひ自分のプロジェクトの武器・敵・ギミックにも AmmoCounter をペタペタ貼って、継承より合成な設計を試してみてください。