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 をプロジェクトに追加

  1. 上記のコードを res://components/drop_table/DropTable.gd などに保存します。
  2. エディタをリロードすると、ノード追加ダイアログの「スクリプトクラス」タブDropTable が出てくるはずです。

手順② アイテムシーンを用意する

例えば以下のようなアイテムシーンを作っておきます。

  • Coin.tscn(コイン)
  • SmallPotion.tscn(小回復)
  • BigPotion.tscn(大回復)
  • EpicSword.tscn(エピック武器)
  • LegendaryArmor.tscn(伝説の防具)

中身はなんでもOKですが、2Dなら Area2DNode2D ベースで作っておくと扱いやすいです。

手順③ 敵シーンに DropTable コンポーネントをアタッチ

敵シーンの構成例:

EnemyGoblin (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 ├── Hurtbox (Area2D)
 └── DropTable (Node)  ← これを追加
  1. 敵シーンを開き、+ ノード追加 → 検索欄で「DropTable」と入力 → 追加。
  2. DropTable ノードを選択し、インスペクタから以下を設定します。
    • drop_chance:1.0(100% ドロップ)
    • max_drop_count:2(最大2個まで落とす)
    • max_drop_radius:16.0(敵の周囲16pxの範囲に散らす)
    • auto_drop_on_tree_exited:false(今回は手動で drop() を呼びます)
  3. Item Lists セクションで、各レアリティにシーンを割り当てます。
    • common_itemsCoin.tscn
    • uncommon_itemsSmallPotion.tscn
    • rare_itemsBigPotion.tscn
    • epic_itemsEpicSword.tscn
    • legendary_itemsLegendaryArmor.tscn
  4. 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() を呼ぶのも面倒だな…という場合は、DropTableauto_drop_on_tree_exitedtrue にしておきます。

この設定にしておけば、

  • 敵が 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 をベースに、「敵ごとにスクリプトを増やす」のではなく、「同じコンポーネントをどう組み合わせるか」という発想でゲーム全体を組んでいくと、後からの改造やバランス調整がかなり楽になりますよ。