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: 1start_exp: 0base_exp_to_next: 100growth_factor: 1.4debug_log: true(挙動確認中はオンにすると便利)auto_connect_group:enemyauto_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 シグナルを、Player の ExperienceGainer に接続します。
手順④: シグナルを接続する(手動 or 自動)
手動で接続する場合(まずはこれがおすすめ)
- シーンツリーで
Enemyを選択し、インスペクタ横の「ノード」タブを開きます。 defeatedシグナルを選択し、「接続」ボタンを押します。- 接続先として
Player/ExperienceGainerを選び、メソッド名を_on_defeat_signal_receivedにします。 - このとき、ExperienceGainer 側にすでに同名メソッドがあるので、そのまま使えます。
これで、敵が die() を呼んだときに ExperienceGainer が gain_exp() してくれます。
自動接続する場合(敵が大量にいるときに便利)
- 敵ノードを
enemyグループに追加します。 ExperienceGainerのインスペクタで:auto_connect_group:enemyauto_connect_defeat_signal_name:defeatedauto_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 でも、同じコンポーネントを気持ちよく再利用できます。
ぜひ、自分のプロジェクト用にパラメータやフックを増やして、
「継承に頼らないコンポーネント駆動の経験値システム」を育ててみてください。
