Godot 4 でシューティング系のプレイヤーや敵を作るとき、Player.gd や Gun.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 をアタッチ
Nodeを追加して名前をAmmoCounterにする- そのノードに上記の
AmmoCounter.gdをアタッチ - インスペクタで
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 ノードがあって…」という深いツリーを作らず、Node1個追加で完結します。 - 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 をペタペタ貼って、継承より合成な設計を試してみてください。
