敵が倒れたときにアイテムを落とす処理、つい継承や「Enemy」という巨大ベースシーンに全部押し込みがちですよね。
でもそうすると、「この敵はドロップする」「この敵はしない」「ボスだけレアドロップ」みたいな条件が増えるたびに、親クラスがどんどん肥大化していきます。
Godot標準の書き方だと、例えばこうなりがちです:
Enemy.gdのdie()にドロップ処理をベタ書き- 別の敵クラスでもコピペ or 継承チェーンを伸ばす
- 「このシーンだけドロップ内容を変えたい」時に、スクリプトを開いて書き換え
これ、コンポーネント化してしまえばかなりスッキリします。
「死ぬときにアイテムを落とす」という責務だけを持ったコンポーネントを作って、敵や壊れるオブジェクトにポン付けするだけにしてしまいましょう。
そこで登場するのが、今回紹介する LootDropper コンポーネント です。
親ノードが queue_free() される直前に、設定した確率でアイテムシーンをその場にスポーンしてくれます。
【Godot 4】死ぬ直前にポロッとアイテム!「LootDropper」コンポーネント
このコンポーネントは、「親が消えるときにだけ動く」という一点に特化しています。
敵、壊れるツボ、宝箱、動く箱など、なんでも親にアタッチして使えます。
フルコード(GDScript / Godot 4)
extends Node
class_name LootDropper
## 親ノードが queue_free() される直前に、
## 確率でアイテムシーンをその場に生成するコンポーネント。
##
## 使い方:
## - 敵や壊れるオブジェクトの子としてこのノードを追加
## - Inspector から drop_scenes, drop_chance などを設定
## - 親側は普通に queue_free() を呼ぶだけでOK
@export_range(0.0, 1.0, 0.01)
var drop_chance: float = 0.5
## ドロップが発生する確率(0.0〜1.0)
## 例: 0.25 = 25% の確率でドロップ
@export var allow_multiple_drops: bool = false
## true の場合、drop_scenes の数だけ判定して複数ドロップもありにする
## false の場合、ドロップするのは高々1つ(最初に当たったものだけ)
@export var use_parent_global_position: bool = true
## true: 親の global_position にアイテムをスポーン(2D向け)
## false: 親の global_transform.origin にスポーン(3D向け)
## ※親の種類によって自動判別もできますが、シンプルさ優先で手動切替にしています
@export var random_rotation_2d: bool = false
## 2D用: ドロップしたアイテムにランダムな回転を付けるかどうか
@export var random_offset_radius: float = 0.0
## スポーン位置をランダムにずらす半径(0 ならずらさない)
## 2D: X-Y 平面、3D: X-Z 平面 でランダムオフセット
@export var drop_scenes: Array[PackedScene] = []
## 候補となるアイテムシーンのリスト
## 例: [Coin.tscn, Heart.tscn, RareItem.tscn]
## allow_multiple_drops=false なら、リストの中から1つだけ出る可能性があります
@export var auto_connect_parent_tree_exited: bool = true
## true: 親の tree_exited シグナルに自動で接続し、
## 親がツリーから抜けるタイミング(= queue_free 直前)でドロップ判定を行う
## false: 外部から manually_drop() / try_drop_now() を呼んで制御したいとき用
@export var debug_log: bool = false
## デバッグ用ログを出すかどうか
func _ready() -> void:
if auto_connect_parent_tree_exited and get_parent() != null:
# 親の tree_exited シグナルに接続
# 親が queue_free されたときに呼ばれる
get_parent().tree_exited.connect(_on_parent_tree_exited)
func _on_parent_tree_exited() -> void:
# 親がツリーから抜ける直前に呼ばれる
if debug_log:
print("[LootDropper] Parent is exiting tree, trying drop...")
_try_drop_internal()
func _try_drop_internal() -> void:
if drop_scenes.is_empty():
if debug_log:
print("[LootDropper] No drop_scenes set, skip.")
return
if allow_multiple_drops:
_drop_multiple()
else:
_drop_single()
func _drop_single() -> void:
# 全体で1回だけ判定して、成功したら1つだけドロップ
if randf() <= drop_chance:
# ドロップするシーンをランダムに1つ選ぶ
var scene: PackedScene = drop_scenes[randi() % drop_scenes.size()]
_spawn_item(scene)
if debug_log:
print("[LootDropper] Dropped single item: ", scene.resource_path)
elif debug_log:
print("[LootDropper] Single drop failed by chance.")
func _drop_multiple() -> void:
# リストの各要素ごとに drop_chance で判定する
for scene in drop_scenes:
if randf() <= drop_chance:
_spawn_item(scene)
if debug_log:
print("[LootDropper] Dropped item (multi): ", scene.resource_path)
func _spawn_item(scene: PackedScene) -> void:
if scene == null:
return
var item := scene.instantiate()
# 親の位置を基準にスポーン
var parent_node := get_parent()
if parent_node == null:
if debug_log:
print("[LootDropper] No parent, cannot spawn item.")
return
var spawn_position := Vector3.ZERO
if use_parent_global_position:
# 2D ノードを想定(Node2D / CharacterBody2D など)
if parent_node.has_method("get_global_position"):
# Godot 4 では Node2D / Control が global_position プロパティを持つ
spawn_position = Vector3(parent_node.global_position.x, parent_node.global_position.y, 0.0)
else:
# 3D ノードでも origin から2D的に落としたい場合のフォールバック
if parent_node.has_method("get_global_transform"):
spawn_position = parent_node.global_transform.origin
else:
# 3D ノードを想定(Node3D / CharacterBody3D など)
if parent_node.has_method("get_global_transform"):
spawn_position = parent_node.global_transform.origin
elif parent_node.has_method("get_global_position"):
spawn_position = Vector3(parent_node.global_position.x, parent_node.global_position.y, 0.0)
# ランダムオフセットを適用
if random_offset_radius > 0.0:
var angle := randf() * TAU
var radius := randf() * random_offset_radius
var offset := Vector3(cos(angle) * radius, sin(angle) * radius, 0.0)
# 3D の場合は XZ 平面にオフセットしてもよいが、ここではシンプルに XY に適用
spawn_position += offset
# 2D or 3D で位置設定を分岐
if item is Node2D:
(item as Node2D).global_position = Vector2(spawn_position.x, spawn_position.y)
if random_rotation_2d:
(item as Node2D).rotation = randf() * TAU
elif item is Node3D:
var t := (item as Node3D).global_transform
t.origin = spawn_position
(item as Node3D).global_transform = t
else:
# 位置を持たないノードの場合は、そのまま親と同じ階層に追加するだけ
if debug_log:
print("[LootDropper] Spawned node has no obvious position property.")
# 親と同じ階層(親の親)にアイテムを追加
var parent_parent := parent_node.get_parent()
if parent_parent != null:
parent_parent.add_child(item)
else:
# 親がすでにルートだった場合は、自分の親(=親)に追加
parent_node.add_child(item)
# --- 外部から明示的に呼び出せるAPI ---
func try_drop_now() -> void:
## 外部から即座にドロップ判定を行いたいときに呼ぶ。
## 例: HPが0になった瞬間にアイテムを出したいが、
## queue_free() のタイミングとは別にしたい場合など。
_try_drop_internal()
func force_drop_all() -> void:
## drop_scenes をすべて確定でドロップする(確率判定なし)
for scene in drop_scenes:
_spawn_item(scene)
使い方の手順
ここでは 2D の敵がコインを落とすケースを例に説明します。
Enemy (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── LootDropper (Node)
手順①:アイテムシーンを用意する
Coin.tscnなど、ドロップさせたいアイテムシーンを作る- 中身は
Node2D/Area2Dなど何でもOK
Coin (Area2D) ├── Sprite2D └── CollisionShape2D
手順②:敵シーンに LootDropper を追加する
- 敵シーン(
Enemy.tscn)を開く - 子ノードとして
Nodeを追加し、スクリプトに上記LootDropper.gdをアタッチ - Inspector で以下を設定:
- drop_scenes:
Coin.tscnをドラッグ&ドロップ - drop_chance: 0.5(50% でドロップ)など
- allow_multiple_drops: false(1つだけ落とす)
- use_parent_global_position: true(2D用)
- random_offset_radius: 8 くらいにすると、少しバラけて落ちる
- random_rotation_2d: true にするとコインがランダム向きで出る
- drop_scenes:
手順③:敵の「死亡処理」はいつも通り書く
敵側のスクリプトは、特別なことをしなくてOKです。
HP が 0 になったら queue_free() するだけで、LootDropper が自動的に反応します。
extends CharacterBody2D
var hp: int = 10
func take_damage(amount: int) -> void:
hp -= amount
if hp <= 0:
die()
func die() -> void:
# ここでドロップ処理を書かなくていいのがポイント!
queue_free()
これで、敵の死亡処理からドロップの責務が完全に分離されました。
「この敵はドロップしないでほしい」と思ったら LootDropper ノードを削除するだけでOKです。
手順④:別のオブジェクトにも再利用する
例えば「壊れるツボ」シーンにも同じコンポーネントを付けるだけで、即ドロップ対応できます。
BreakablePot (StaticBody2D) ├── Sprite2D ├── CollisionShape2D └── LootDropper (Node)
extends StaticBody2D
func break_pot() -> void:
# パーティクルやSEを再生したあと…
queue_free() # LootDropper が勝手にアイテムを出してくれる
「敵」「ツボ」「宝箱」など、種類が違っても、全部同じ LootDropper を使い回せるのがコンポーネント指向の気持ちよさですね。
メリットと応用
- 死亡処理とドロップ処理を分離できるので、Enemy スクリプトがスリムになる
- 「このシーンだけドロップ内容を変えたい」とき、Inspector でシーン差し替えするだけ
- 敵以外(ツボ、宝箱、ギミック)にもそのまま使えるので、ロジックの再利用性が高い
- ノード階層は浅いまま、「LootDropper」という意味のあるコンポーネントがぶら下がるので、シーン構造の見通しも良くなる
レベルデザイン的にも、各ステージの敵シーンごとにドロップテーブルを差し替えるだけでバランス調整できるのが嬉しいところです。
ゲームデザイナーや自分の未来の自分が、スクリプトを開かずに調整できるようになります。
改造案:ウェイト付きドロップ(レア度対応)
「レアアイテムだけ出にくくしたい」ときは、シンプルなウェイト付き抽選を追加するのが定番です。
例えば、以下のようなヘルパー関数を LootDropper に追加して、_drop_single() から呼び出す形にすると良いですね。
@export var drop_weights: Array[float] = []
## drop_scenes と同じ長さの配列を用意し、各アイテムの重み(出やすさ)を指定
## 例: [10.0, 1.0] なら、0番目は1番目の10倍出やすい
func _pick_weighted_scene() -> PackedScene:
if drop_scenes.is_empty():
return null
if drop_weights.is_empty() or drop_weights.size() != drop_scenes.size():
# ウェイト未指定 or サイズ不一致なら普通にランダム
return drop_scenes[randi() % drop_scenes.size()]
var total := 0.0
for w in drop_weights:
total += max(w, 0.0)
if total <= 0.0:
return drop_scenes[randi() % drop_scenes.size()]
var r := randf() * total
var acc := 0.0
for i in drop_scenes.size():
acc += max(drop_weights[i], 0.0)
if r <= acc:
return drop_scenes[i]
return drop_scenes.back()
この関数を使えば、_drop_single() 内の
var scene: PackedScene = drop_scenes[randi() % drop_scenes.size()]
を
var scene: PackedScene = _pick_weighted_scene()
に差し替えるだけで、レア度付きドロップに進化します。
こんな感じで、LootDropper 自体を継承で増築するのではなく、小さな関数や設定を足していくスタイルにしておくと、コンポーネント指向らしくスケールしていきますね。
