GodotでRPGっぽい経験値システムを作るとき、つい「Playerを継承した専用クラス」を作りがちですよね。
その中に HP や MP、レベル、経験値…と全部まとめて書いていくと、最初は気持ちいいんですが、だんだん「この敵にも経験値ほしいな」「動く床にも経験値トリガーを付けたい」みたいなときに、継承構造が邪魔になってきます。

さらに、敵を倒したときに「どのノードが経験値を持っているのか」「どこにシグナルをつなぐのか」がバラバラだと、シーンツリーがカオスになりがちです。

そこで今回は、「経験値だけ」を責務とするコンポーネント ExperienceGainer を用意して、
プレイヤーでも敵でも、経験値を持たせたいノードにポン付けするだけのスタイルにしてみましょう。

【Godot 4】敵撃破シグナルを食べて育つ!「ExperienceGainer」コンポーネント

このコンポーネントは、

  • 敵が発行する「倒された」シグナル(例: defeated)を受け取る
  • 経験値(EXP)を加算する
  • レベルアップ判定を行い、レベルアップ時にシグナルを発行する

という「経験値管理」に特化したコンポーネントです。
プレイヤーの移動や攻撃ロジックとは完全に分離されているので、シーン構造をスッキリ保てます。


GDScript フルコード


extends Node
class_name ExperienceGainer
## 経験値とレベルアップを管理するコンポーネント。
## - 敵などから送られてくる「撃破シグナル」を受け取りEXPを加算
## - レベルアップ判定を行い、レベルアップ時にシグナルを発行
## - プレイヤーや敵など、経験値を持たせたいノードにアタッチして使う

## --- エディタから設定できるパラメータ群 ---

@export_category("Level / EXP Settings")

@export var start_level: int = 1:
	set(value):
		start_level = max(1, value)

## 初期の経験値。ロード時にセーブデータを反映したい場合などに利用。
@export var start_exp: int = 0:
	set(value):
		start_exp = max(0, value)

## レベルアップに必要な基礎経験値。
## 例: base_exp_to_next = 100, growth_factor = 1.5
##   L1 -> L2 に必要: 100
##   L2 -> L3 に必要: 150
##   L3 -> L4 に必要: 225 ... のように増えていく
@export var base_exp_to_next: int = 100:
	set(value):
		base_exp_to_next = max(1, value)

## レベルが上がるごとの必要経験値の倍率。
## 1.0 なら毎レベル固定、1.5 なら1.5倍ずつ増えていく。
@export var growth_factor: float = 1.3:
	set(value):
		growth_factor = max(1.0, value)

## 一度に上がるレベルの上限。
## 大量の経験値をもらっても、いきなり+10レベル…を防ぎたい場合に。
@export var max_level_up_per_gain: int = 10:
	set(value):
		max_level_up_per_gain = max(1, value)

@export_category("Signal / Debug")

## true にすると、経験値取得やレベルアップのログを出力。
@export var debug_log: bool = false

## 敵側のシグナル名をここに書いておくと、_ready で自動接続を試みる。
## 例: "defeated", "killed", "died" など。
@export var auto_connect_defeat_signal_name: StringName = "defeated"

## 敵を自動接続する範囲(2D用)。プレイヤーの周囲にいる敵に対して自動接続したい場合などに。
## 0 以下なら自動接続を行わない。
@export var auto_connect_radius: float = 0.0

## 経験値を受け取る対象のグループ名。
## 例: 敵側が "enemy" グループに入っている場合に "enemy" を指定。
@export var auto_connect_group: StringName = "enemy"


## --- シグナル ---

## 経験値が増えたときに発行される。
signal exp_changed(current_exp: int, gained: int)

## レベルが上がったときに発行される。
signal level_up(new_level: int, previous_level: int)

## 経験値がリセット/ロードされたときに発行される。
signal exp_reset(current_level: int, current_exp: int)


## --- 内部状態 ---

var level: int
var exp: int

func _ready() -> void:
	## 初期値をセット
	level = start_level
	exp = start_exp

	if debug_log:
		print("[ExperienceGainer] Ready. level=%d, exp=%d" % [level, exp])

	## 自動接続を行う(必要なら)
	if auto_connect_radius > 0.0 and auto_connect_group != "":
		_auto_connect_defeat_signals()


## 現在のレベルを取得するヘルパー。
func get_level() -> int:
	return level


## 現在の経験値を取得するヘルパー。
func get_exp() -> int:
	return exp


## 現在のレベルから、次のレベルに必要な経験値量を計算する。
func get_required_exp_for_next_level(current_level: int = -1) -> int:
	if current_level <= 0:
		current_level = level
	# base * (growth_factor ^ (level - 1))
	var required := int(round(base_exp_to_next * pow(growth_factor, float(current_level - 1))))
	return max(1, required)


## 経験値を加算するメインAPI。
## 敵側のシグナルから直接呼んでもOK。
func gain_exp(amount: int) -> void:
	if amount <= 0:
		return

	var gained := amount
	exp += amount

	if debug_log:
		print("[ExperienceGainer] Gained EXP: +%d (total=%d)" % [gained, exp])

	emit_signal("exp_changed", exp, gained)

	# レベルアップ判定
	_process_level_up()


## レベルと経験値を任意の値にセットする(セーブデータ読み込みなどに)。
func set_level_and_exp(new_level: int, new_exp: int) -> void:
	level = max(1, new_level)
	exp = max(0, new_exp)

	if debug_log:
		print("[ExperienceGainer] Set state: level=%d, exp=%d" % [level, exp])

	emit_signal("exp_reset", level, exp)


## 内部用: レベルアップ処理
func _process_level_up() -> void:
	var level_up_count := 0

	while level_up_count < max_level_up_per_gain:
		var required := get_required_exp_for_next_level(level)
		if exp < required:
			break

		# 必要経験値を消費してレベルアップ
		exp -= required
		var previous_level := level
		level += 1
		level_up_count += 1

		if debug_log:
			print("[ExperienceGainer] Level Up! %d -> %d (remaining exp=%d)" % [previous_level, level, exp])

		emit_signal("level_up", level, previous_level)

	# ループを抜けた後の状態をログ
	if debug_log and level_up_count == 0:
		print("[ExperienceGainer] No level up. level=%d, exp=%d / required=%d" % [
			level, exp, get_required_exp_for_next_level(level)
		])


## 内部用: 指定グループのノードから defeat シグナルを自動接続
func _auto_connect_defeat_signals() -> void:
	if auto_connect_defeat_signal_name == "":
		return

	var world := get_tree()
	if not world:
		return

	# 2D想定: 自身の位置から一定半径内のノードを探して接続する
	var my_2d := owner if owner is Node2D else self
	if not (my_2d is Node2D):
		if debug_log:
			print("[ExperienceGainer] auto_connect_radius is set but owner is not Node2D.")
		return

	var my_pos: Vector2 = (my_2d as Node2D).global_position
	var candidates := world.get_nodes_in_group(auto_connect_group)

	for node in candidates:
		if not (node is Node2D):
			continue

		var n2d := node as Node2D
		if my_pos.distance_to(n2d.global_position) > auto_connect_radius:
			continue

		# 敵側が該当シグナルを持っているか確認
		if not node.has_signal(auto_connect_defeat_signal_name):
			continue

		# すでに接続されていないか確認しつつ接続
		var signal_name := auto_connect_defeat_signal_name
		if not node.is_connected(signal_name, Callable(self, "_on_defeat_signal_received")):
			node.connect(signal_name, Callable(self, "_on_defeat_signal_received"))

			if debug_log:
				print("[ExperienceGainer] Connected defeat signal from %s" % node.name)


## 敵側の defeat シグナルから呼ばれるコールバック。
## シグナル引数に exp_amount が含まれていることを想定。
## 例: signal defeated(exp_amount: int)
func _on_defeat_signal_received(exp_amount: int = 0) -> void:
	if exp_amount <= 0:
		if debug_log:
			print("[ExperienceGainer] Received defeat signal without positive EXP.")
		return

	gain_exp(exp_amount)

使い方の手順

ここでは 2D アクションゲーム風の例で、プレイヤーが敵を倒して経験値を得るケースを想定します。

手順①: コンポーネントスクリプトを用意する

  • 上記の ExperienceGainer.gd をプロジェクトに保存します(例: res://components/ExperienceGainer.gd)。
  • Godot エディタを再読み込みすると、ノード追加のスクリプト一覧や、スクリプトアタッチ時に ExperienceGainer として選べるようになります。

手順②: プレイヤーに ExperienceGainer をアタッチ

プレイヤーシーンの構成例:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── ExperienceGainer (Node)
  • Player シーンを開き、子ノードとして Node を追加し、名前を ExperienceGainer にします。
  • そのノードに ExperienceGainer.gd をアタッチします。
  • インスペクタで以下のように設定してみましょう:
    • start_level: 1
    • start_exp: 0
    • base_exp_to_next: 100
    • growth_factor: 1.4
    • debug_log: true(挙動確認中はオンにすると便利)
    • auto_connect_group: enemy
    • auto_connect_radius: 0(まずは手動接続で試す場合)

プレイヤー本体のスクリプトからレベルを参照したい場合:


# Player.gd (例)
extends CharacterBody2D

@onready var exp_gainer: ExperienceGainer = $ExperienceGainer

func _ready() -> void:
	# レベルアップ時にステータスを伸ばすなど
	exp_gainer.level_up.connect(_on_level_up)

func _on_level_up(new_level: int, previous_level: int) -> void:
	print("Player leveled up: %d -> %d" % [previous_level, new_level])
	# 例: HP を増やすなど
	# max_hp += 10

手順③: 敵側に「倒された」シグナルを用意する

敵シーンの構成例:

Enemy (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Hurtbox (Area2D)

敵スクリプトの例:


# Enemy.gd
extends CharacterBody2D

## 倒されたときに発行するシグナル。
## ExperienceGainer 側では "defeated" をデフォルトで受け取るようにしている。
signal defeated(exp_amount: int)

@export var exp_reward: int = 30

var hp: int = 50

func take_damage(amount: int) -> void:
	hp -= amount
	if hp <= 0:
		die()

func die() -> void:
	# 経験値を含めてシグナル発行
	emit_signal("defeated", exp_reward)
	queue_free()

この defeated シグナルを、PlayerExperienceGainer に接続します。

手順④: シグナルを接続する(手動 or 自動)

手動で接続する場合(まずはこれがおすすめ)
  1. シーンツリーで Enemy を選択し、インスペクタ横の「ノード」タブを開きます。
  2. defeated シグナルを選択し、「接続」ボタンを押します。
  3. 接続先として Player/ExperienceGainer を選び、メソッド名を _on_defeat_signal_received にします。
  4. このとき、ExperienceGainer 側にすでに同名メソッドがあるので、そのまま使えます。

これで、敵が die() を呼んだときに ExperienceGainergain_exp() してくれます。

自動接続する場合(敵が大量にいるときに便利)
  • 敵ノードを enemy グループに追加します。
  • ExperienceGainer のインスペクタで:
    • auto_connect_group: enemy
    • auto_connect_defeat_signal_name: defeated
    • auto_connect_radius: 1000 など、プレイヤー周囲をカバーできる距離に設定

これで、_ready() 時に周囲の enemy グループのノードを走査し、defeated シグナルを自動で _on_defeat_signal_received に接続してくれます。


メリットと応用

ExperienceGainer を使うことで、次のようなメリットがあります。

  • プレイヤーのスクリプトが「移動」「攻撃」「経験値管理」で肥大化しない
    経験値まわりのロジックは全部コンポーネントに隔離されるので、プレイヤー本体は「どう動くか」「どう攻撃するか」だけに集中できます。
  • 敵やNPCにもそのまま流用できる
    経験値を得るのはプレイヤーだけとは限りません。仲間キャラやペットなどにも ExperienceGainer を付けるだけで、同じロジックを共有できます。
  • シーン構造がフラットで見通しが良い
    「PlayerBase」「MagePlayer」「WarriorPlayer」みたいな継承ツリーを増やす代わりに、
    Player + ExperienceGainer + AttackComponent + … とコンポーネントを積み上げる構造にできます。
  • テストやデバッグがしやすい
    ExperienceGainer 単体でテストシーンを作り、ボタンを押すと gain_exp(50) する…といった検証が簡単です。

「継承より合成」の良さが一番わかりやすく出るのが、こういうステータス系のロジックですね。

改造案: レベルアップ時に自動でステータスを伸ばす

例えば、ExperienceGainer に「レベルアップ時に HP を増やす」処理を足したい場合、
プレイヤー側に書くのではなく、コールバックを登録できるようにするとさらに柔軟になります。

こんな感じで、小さなフックを追加してみましょう:


# ExperienceGainer.gd 内に追記

## レベルアップ時に呼ばれるコールバック(任意で設定)
var on_level_up_callback: Callable = Callable()

func set_on_level_up_callback(callback: Callable) -> void:
	on_level_up_callback = callback

func _process_level_up() -> void:
	var level_up_count := 0

	while level_up_count < max_level_up_per_gain:
		var required := get_required_exp_for_next_level(level)
		if exp < required:
			break

		exp -= required
		var previous_level := level
		level += 1
		level_up_count += 1

		emit_signal("level_up", level, previous_level)

		# ここで任意の処理を呼び出せる
		if on_level_up_callback.is_valid():
			on_level_up_callback.call(level, previous_level)

プレイヤー側ではこう使えます:


# Player.gd
@onready var exp_gainer: ExperienceGainer = $ExperienceGainer

func _ready() -> void:
	exp_gainer.set_on_level_up_callback(Callable(self, "_on_level_up"))

func _on_level_up(new_level: int, previous_level: int) -> void:
	max_hp += 10
	print("HP increased! max_hp =", max_hp)

このように、ExperienceGainer は「経験値とレベル」の責務だけを持ち
「レベルアップしたときに何をするか」は外部から差し込む、というスタイルにすると、
プレイヤーでも敵でも NPC でも、同じコンポーネントを気持ちよく再利用できます。

ぜひ、自分のプロジェクト用にパラメータやフックを増やして、
「継承に頼らないコンポーネント駆動の経験値システム」を育ててみてください。