Godot 4で敵のドロップ処理を書くとき、ついこんな構成になりがちですよね。
- 敵ごとに専用スクリプトを生やして、
_on_dead()の中でmatch文だらけ - 「この敵はポーション20%、コイン80%」みたいな確率をベタ書き
- レアリティごとに処理を分けはじめて、あっという間にスパゲッティ
さらに継承ベースで BaseEnemy.gd にドロップ処理をまとめてしまうと、
- ボスだけ特殊ドロップ → サブクラスで上書き → 条件分岐が増える
- 「この敵だけドロップ無しにしたい」みたいな時に継承チェーンをいじる羽目になる
…と、ドロップロジックが「敵クラス」にベッタリくっついてしまって、管理がつらくなりがちです。
そこで今回は、敵からドロップ処理を切り離して「コンポーネント」としてアタッチするだけで使える、DropTable(ドロップテーブル)コンポーネントを作っていきましょう。敵のスクリプトは「死んだこと」を通知するだけ、何をどれくらい落とすかは DropTable コンポーネントに丸投げするスタイルです。
【Godot 4】レアリティ付きドロップをコンポーネント化!「DropTable」コンポーネント
この DropTable コンポーネントは、
- レアリティごとにアイテムシーンをリスト管理
- レアリティ自体の出現確率もエディタから調整
- 敵が死んだときに
drop()を呼ぶだけで、自動で抽選&生成
という構成になっています。敵クラスに「確率」や「シーンパス」を一切書かないのがポイントですね。
DropTable.gd フルコード
extends Node
class_name DropTable
"""
敵などにアタッチして使う「ドロップテーブル」コンポーネント。
・レアリティごとにアイテムシーンのリストを管理
・レアリティごとの出現確率を設定
・drop() を呼ぶとランダム抽選してアイテムシーンをインスタンス化&配置
想定用途:
- 敵の死亡時ドロップ
- 宝箱を開けた時の中身抽選
- 破壊可能オブジェクト(ツボ、木箱など)のドロップ抽選
"""
# レアリティの種類を列挙
enum Rarity {
COMMON,
UNCOMMON,
RARE,
EPIC,
LEGENDARY,
}
# レアリティごとの表示名(デバッグ用)
const RARITY_LABELS := {
Rarity.COMMON: "Common",
Rarity.UNCOMMON: "Uncommon",
Rarity.RARE: "Rare",
Rarity.EPIC: "Epic",
Rarity.LEGENDARY: "Legendary",
}
# --- エディタから設定するパラメータ群 ---
@export_category("Drop Settings")
@export var auto_drop_on_tree_exited: bool = false:
"""
true にすると、このノードの親がシーンから消えたタイミング(例: 敵死亡で queue_free)で自動ドロップします。
敵スクリプト側で明示的に drop() を呼びたくない場合に便利です。
"""
@export var drop_on_ready_test: bool = false:
"""
デバッグ用。true にすると _ready で一度だけ drop() を呼びます。
ゲーム中では false にしておきましょう。
"""
@export_range(0.0, 1.0, 0.01)
var drop_chance: float = 1.0:
"""
全体のドロップ発生確率。
0.0 = 絶対に何も落とさない
1.0 = 必ず何か落とす
0.5 = 50% の確率で何か落とす
"""
@export_range(1, 10, 1)
var max_drop_count: int = 1:
"""
一度の drop() 呼び出しで、最大いくつまでアイテムを生成するか。
1 の場合は「1個だけ」。
2 以上にすると、複数個ドロップする可能性も出てきます。
"""
@export_range(0.0, 10.0, 0.1)
var max_drop_radius: float = 1.0:
"""
ドロップを親ノードの位置からどれだけ散らすか(半径)。
0.0 にすると、すべて同じ位置に重なって生成されます。
"""
@export_category("Rarity Weights")
@export_range(0.0, 1.0, 0.01)
var weight_common: float = 0.6
@export_range(0.0, 1.0, 0.01)
var weight_uncommon: float = 0.25
@export_range(0.0, 1.0, 0.01)
var weight_rare: float = 0.1
@export_range(0.0, 1.0, 0.01)
var weight_epic: float = 0.04
@export_range(0.0, 1.0, 0.01)
var weight_legendary: float = 0.01
@export_category("Item Lists")
@export var common_items: Array[PackedScene] = []:
"""
Common レアリティのアイテム候補。
例: コイン、しょぼい回復アイテムなど。
"""
@export var uncommon_items: Array[PackedScene] = []:
"""
Uncommon レアリティのアイテム候補。
例: 通常ポーション、少しレアな素材など。
"""
@export var rare_items: Array[PackedScene] = []:
"""
Rare レアリティのアイテム候補。
例: 強力なポーション、レア装備など。
"""
@export var epic_items: Array[PackedScene] = []:
"""
Epic レアリティのアイテム候補。
"""
@export var legendary_items: Array[PackedScene] = []:
"""
Legendary レアリティのアイテム候補。
"""
# --- シグナル ---
signal item_dropped(item_node: Node, rarity: int)
"""
アイテムが生成されたときに発火します。
UI 側で「レジェンダリー!」演出をしたい場合などに接続して使えます。
"""
signal no_item_dropped()
"""
drop() が呼ばれたが、確率的に何もドロップしなかった時に発火します。
"""
func _ready() -> void:
if drop_on_ready_test:
drop()
# 親が queue_free されたタイミングを検知して自動ドロップ
if auto_drop_on_tree_exited and get_parent():
get_parent().tree_exited.connect(_on_parent_tree_exited)
func _on_parent_tree_exited() -> void:
# 親がツリーから消える直前/直後に呼ばれるので、ここでドロップ処理を行う
drop()
func drop() -> void:
"""
ドロップ抽選を実行します。
敵の死亡処理などから呼び出してください。
"""
# まずは全体のドロップ発生判定
if randf() > drop_chance:
emit_signal("no_item_dropped")
return
var parent := get_parent()
if parent == null:
push_warning("DropTable has no parent. Items will be spawned at (0,0).")
# 何個ドロップするか(1〜max_drop_count のランダム)
var drop_count := randi_range(1, max_drop_count)
for i in drop_count:
var rarity := _pick_rarity()
var item_scene := _pick_item_scene_for_rarity(rarity)
if item_scene == null:
# 該当レアリティのリストが空の場合はスキップ
continue
var item_instance := item_scene.instantiate()
# 2D/3D のどちらにもそれなりに対応できるように最低限の対応
_place_item_instance(item_instance, parent)
emit_signal("item_dropped", item_instance, rarity)
func _pick_rarity() -> int:
"""
レアリティの重みに基づいて 1 つ選択します。
重みが 0 のレアリティは選ばれません。
"""
var weights := {
Rarity.COMMON: weight_common,
Rarity.UNCOMMON: weight_uncommon,
Rarity.RARE: weight_rare,
Rarity.EPIC: weight_epic,
Rarity.LEGENDARY: weight_legendary,
}
var total_weight := 0.0
for w in weights.values():
total_weight += w
# すべて 0 の場合は強制的に Common 扱い
if total_weight <= 0.0:
return Rarity.COMMON
var r := randf() * total_weight
var accum := 0.0
for rarity in weights.keys():
accum += weights[rarity]
if r <= accum:
return rarity
# 浮動小数の誤差対策。ここまで来ることはほぼ無いが、最後のレアリティを返す
return Rarity.LEGENDARY
func _pick_item_scene_for_rarity(rarity: int) -> PackedScene:
"""
選ばれたレアリティに対応するアイテムリストから 1 つランダムに選ぶ。
リストが空の場合は null を返します。
"""
var list: Array[PackedScene] = []
match rarity:
Rarity.COMMON:
list = common_items
Rarity.UNCOMMON:
list = uncommon_items
Rarity.RARE:
list = rare_items
Rarity.EPIC:
list = epic_items
Rarity.LEGENDARY:
list = legendary_items
_:
list = common_items
if list.is_empty():
return null
var index := randi_range(0, list.size() - 1)
return list[index]
func _place_item_instance(item_instance: Node, parent: Node) -> void:
"""
生成したアイテムを、親ノードの近くに配置してツリーに追加します。
2D/3D をざっくり判定して、それぞれの Transform を扱います。
"""
if parent and parent.get_parent():
parent.get_parent().add_child(item_instance)
else:
# 親がいない場合はとりあえずこの DropTable の親にぶら下げる
add_child(item_instance)
# 位置決め
var offset := Vector3.ZERO
if max_drop_radius > 0.0:
# ランダムな方向に散らす。2D/3D 両対応のために XZ or XY を使い分ける。
var angle := randf() * TAU
offset.x = cos(angle) * max_drop_radius
offset.y = sin(angle) * max_drop_radius
# 2D ノードか 3D ノードかを判定して位置を設定
if "global_position" in parent:
# 2D or 3D 共通プロパティだが、型で分けたい場合は is Node2D などを使う
if item_instance is Node2D and parent is Node2D:
item_instance.global_position = parent.global_position + Vector2(offset.x, offset.y)
elif "global_transform" in parent and "global_transform" in item_instance:
var t: Transform3D = parent.global_transform
t.origin += offset
item_instance.global_transform = t
else:
# 最低限のフォールバックとして同じ位置に置く
if "global_position" in item_instance:
item_instance.global_position = parent.global_position
else:
# 親が位置情報を持っていない場合は、原点に配置
if "position" in item_instance:
item_instance.position = Vector2.ZERO
使い方の手順
ここでは 2D アクションゲームの「敵が死んだらアイテムを落とす」例で説明します。
手順① DropTable.gd をプロジェクトに追加
- 上記のコードを
res://components/drop_table/DropTable.gdなどに保存します。 - エディタをリロードすると、ノード追加ダイアログの「スクリプトクラス」タブに
DropTableが出てくるはずです。
手順② アイテムシーンを用意する
例えば以下のようなアイテムシーンを作っておきます。
Coin.tscn(コイン)SmallPotion.tscn(小回復)BigPotion.tscn(大回復)EpicSword.tscn(エピック武器)LegendaryArmor.tscn(伝説の防具)
中身はなんでもOKですが、2Dなら Area2D や Node2D ベースで作っておくと扱いやすいです。
手順③ 敵シーンに DropTable コンポーネントをアタッチ
敵シーンの構成例:
EnemyGoblin (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D ├── Hurtbox (Area2D) └── DropTable (Node) ← これを追加
- 敵シーンを開き、+ ノード追加 → 検索欄で「DropTable」と入力 → 追加。
DropTableノードを選択し、インスペクタから以下を設定します。drop_chance:1.0(100% ドロップ)max_drop_count:2(最大2個まで落とす)max_drop_radius:16.0(敵の周囲16pxの範囲に散らす)auto_drop_on_tree_exited:false(今回は手動で drop() を呼びます)
- Item Lists セクションで、各レアリティにシーンを割り当てます。
common_items:Coin.tscnuncommon_items:SmallPotion.tscnrare_items:BigPotion.tscnepic_items:EpicSword.tscnlegendary_items:LegendaryArmor.tscn
- Rarity Weights でレアリティの出現確率を調整します(例):
- Common: 0.7
- Uncommon: 0.2
- Rare: 0.08
- Epic: 0.015
- Legendary: 0.005
手順④ 敵の死亡時に DropTable.drop() を呼ぶ
敵スクリプト側では、「死んだ」ことだけを管理し、ドロップの中身は一切気にしないようにします。
# EnemyGoblin.gd
extends CharacterBody2D
@onready var drop_table: DropTable = $DropTable
var hp: int = 10
func apply_damage(amount: int) -> void:
hp -= amount
if hp <= 0:
die()
func die() -> void:
# アニメーションやSEはここで再生
# ...
# ドロップ処理をコンポーネントに委譲
if drop_table:
drop_table.drop()
queue_free()
これで、敵の死亡処理はどれだけ増えても die() 内で drop_table.drop() を呼ぶだけで済みます。
別パターン:自動ドロップでさらに楽をする
敵スクリプトから drop() を呼ぶのも面倒だな…という場合は、DropTable の auto_drop_on_tree_exited を true にしておきます。
この設定にしておけば、
- 敵が
queue_free()されたタイミング
で自動的に drop() が実行されます。この場合、敵スクリプトの die() はこんな感じでOKです。
func die() -> void:
# アニメーションやSEなど
# ...
queue_free() # DropTable が自動でドロップしてくれる
敵スクリプトは「死ぬこと」だけを責任範囲にしておいて、ドロップという「別の関心事」は完全にコンポーネントに押し出せているのがポイントですね。
メリットと応用
この DropTable コンポーネントを導入すると、いろいろ嬉しいことがあります。
- 敵スクリプトからドロップロジックが消える
敵クラスは「HP管理」「行動AI」「死亡判定」だけに集中できます。ドロップはDropTableに完全委譲。 - レアリティや確率の調整がエディタだけで完結
コードを一切触らずに、weight_*とアイテムリストをいじるだけでバランス調整ができます。 - シーン構造がシンプル
「敵ごとにドロップ処理を継承で分岐する」必要がなく、DropTableノードを付けるかどうか、パラメータをどうするか、だけで済みます。 - 敵以外にもそのまま使い回せる
宝箱、ツボ、木箱、ボス報酬など、「何かを壊したら中身が出る」という全てのパターンで再利用できます。
例えば宝箱シーンでも、こんな感じで組めます。
TreasureChest (Node2D) ├── Sprite2D ├── CollisionShape2D └── DropTable (Node)
宝箱スクリプト:
extends Node2D
@onready var drop_table: DropTable = $DropTable
var opened: bool = false
func open() -> void:
if opened:
return
opened = true
# 開くアニメーションなど
# ...
# 中身をドロップ
if drop_table:
drop_table.drop()
queue_free()
「敵」と「宝箱」が同じ DropTable を共有しているのが、まさに 継承より合成 のおいしいところですね。
改造案:レアリティごとにログを出す
デバッグ時に「どのレアリティがどれくらい出ているか」を確認したい場合、item_dropped シグナルを使うのもアリですが、簡易的に DropTable 内にログ用のフックを追加するのも手です。
func _log_drop(rarity: int, item_scene: PackedScene) -> void:
var label := RARITY_LABELS.get(rarity, str(rarity))
var scene_path := item_scene.resource_path
print("[DropTable] Dropped rarity=", label, " item=", scene_path)
そして drop() 内の生成ループで、インスタンス化した直後に呼び出します。
var item_instance := item_scene.instantiate()
_log_drop(rarity, item_scene)
この程度の改造なら、コンポーネントの責務を壊さずに「可視化」や「チューニング支援」ができて便利ですね。
この DropTable をベースに、「敵ごとにスクリプトを増やす」のではなく、「同じコンポーネントをどう組み合わせるか」という発想でゲーム全体を組んでいくと、後からの改造やバランス調整がかなり楽になりますよ。
