component-system について
Godot 4.3以降のゲーム開発で、深い継承階層ではなく小さく再利用できるコンポーネントで振る舞いを構成するスキルです。GDScriptとC#で設計、通信、設定、検索のパターンを紹介します。
主な機能
- 01単一責任のコンポーネント
- 02シグナルによる通信
- 03Health、Hitbox、Hurtboxのパターン
- 04インスペクターでの設定
- 05コンポーネントの接続と検索
活用シーン
- 01プレイヤーと敵の間で振る舞いを再利用
- 02ダメージ検出と体力管理の分離
- 03シーン内のエンティティに任意の振る舞いを組み合わせ
- 04固定ノード名に頼らないコンポーネント検索
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ jame581/ godotprompter --skill component-systemインストール後、Claude Code で/component-systemを入力して読み込まれたか確認します。
詳細
Godot 4.3+ のコンポーネントシステム
コンポジションを通じて動作を構築します。継承階層をたどるのではなく、小さく役割を絞ったコンポーネントを任意のエンティティにアタッチします。すべての例は、非推奨 API を使わずに Godot 4.3+ を対象としています。
関連スキル: シーンツリーのコンポジションについては scene-organization、疎結合なコンポーネント間通信については event-bus、データ駆動型のコンポーネント設定については resource-pattern、Area2D/3D の重なり検出とコリジョンシェイプについては physics-system、このパターンを使った AbilityComponent の例については ability-system を参照してください。
1. コンポーネントを使う理由
| 継承に関する問題 | コンポーネントによる解決方法 |
|---|---|
| 深い継承階層は壊れやすく、1つのクラスを変更すると多くに影響します | 各コンポーネントは単一の役割を持つ独立したシーンです |
| 無関係なエンティティ間で動作を共有するには、不自然な基底クラスが必要になります | その動作が必要な任意のエンティティにコンポーネントを追加できます |
| 新しい組み合わせを追加するたびに、新しいサブクラスが必要になります | シーンレベルでコンポーネントを自由に組み合わせられます |
主な利点:
- エンティティ間で再利用 —
HealthComponentは、コードを変更せずにプレイヤー、敵、破壊可能な木箱、ボスで動作します。 - 関心事の分離 — ダメージ検出、ヘルス管理、状態アニメーションは、それぞれ別のファイルで管理します。デバッグも個別に行えます。
- 動作の自由な組み合わせ — 敵に
HitboxComponentとPatrolComponentをそれぞれ追加できます。一方を削除しても、もう一方には影響しません。
2. コンポーネント設計のルール
- 1つのコンポーネントにつき、責務は1つにします。
HealthAndShieldAndRegenComponentのような名前を付けようとしている場合は、分割してください。 - 直接の兄弟ノードへのアクセスではなく、シグナルで通信します。 コンポーネントから
get_parent().get_node("SiblingComponent")を呼び出してはいけません。代わりにシグナルを発行します。 - 可能な限りステートレスにします。 可変状態を保存するより、入力や
@export設定から状態を導出してください。状態が必要な場合は、非公開に保ちます。 - すべての設定に
@exportを使います。 ダメージ量、クールダウン時間、レイヤーマスクは、ハードコードされた定数ではなくインスペクターで設定します。
3. よく使われるコンポーネント
| コンポーネント | 目的 | 主なシグナル |
|---|---|---|
HealthComponent | 現在の HP と最大 HP を追跡し、ダメージと回復を適用します | health_changed(current, maximum)、died |
HitboxComponent | 重なっているヒット対象を検出し、ダメージを与えます | hit(target_hurtbox) |
HurtboxComponent | 攻撃を受け、HealthComponent にダメージを渡します | hurt(damage_amount) |
InteractableComponent | エンティティをインタラクト可能として扱い、プレイヤーとの重なり時にシグナルを発行します | interacted(interactor) |
StateMachineComponent | _process と _physics_process を子のステートノードに委譲します | state_changed(from, to) |
4. HitboxComponent
ダメージを与える任意のエンティティにアタッチします。インスペクターで damage を設定します。
GDScript(hitbox_component.gd)
class_name HitboxComponent
extends Area2D
## Damage dealt to the target hurtbox on contact.
@export var damage: int = 10
## Minimum seconds between successive hits (0 = no cooldown).
@export var cooldown_duration: float = 0.5
signal hit(target_hurtbox: HurtboxComponent)
var _on_cooldown: bool = false
@onready var _cooldown_timer: Timer = _build_timer()
func _ready() -> void:
area_entered.connect(_on_area_entered)
func _on_area_entered(area: Area2D) -> void:
if _on_cooldown:
return
if area is not HurtboxComponent:
return
hit.emit(area)
area.receive_hit(damage)
if cooldown_duration > 0.0:
_on_cooldown = true
_cooldown_timer.start(cooldown_duration)
func _on_cooldown_timeout() -> void:
_on_cooldown = false
func _build_timer() -> Timer:
var t := Timer.new()
t.one_shot = true
t.timeout.connect(_on_cooldown_timeout)
add_child(t)
return t
C#(HitboxComponent.cs)
using Godot;
public partial class HitboxComponent : Area2D
{
/// <summary>Damage dealt to the target hurtbox on contact.</summary>
[Export] public int Damage { get; set; } = 10;
/// <summary>Minimum seconds between successive hits (0 = no cooldown).</summary>
[Export] public float CooldownDuration { get; set; } = 0.5f;
[Signal] public delegate void HitEventHandler(HurtboxComponent targetHurtbox);
private bool _onCooldown;
private Timer _cooldownTimer;
public override void _Ready()
{
_cooldownTimer = new Timer { OneShot = true };
_cooldownTimer.Timeout += OnCooldownTimeout;
AddChild(_cooldownTimer);
AreaEntered += OnAreaEntered;
}
private void OnAreaEntered(Area2D area)
{
if (_onCooldown) return;
if (area is not HurtboxComponent hurtbox) return;
EmitSignal(SignalName.Hit, hurtbox);
hurtbox.ReceiveHit(Damage);
if (CooldownDuration > 0f)
{
_onCooldown = true;
_cooldownTimer.Start(CooldownDuration);
}
}
private void OnCooldownTimeout() => _onCooldown = false;
}
5. HurtboxComponent
ダメージを受ける任意のエンティティにアタッチします。@export を使って、兄弟ノードの HealthComponent に接続します。
GDScript(hurtbox_component.gd)
class_name HurtboxComponent
extends Area2D
## Reference to the HealthComponent on the same entity.
@export var health_component: HealthComponent
## Invincibility frame duration in seconds (0 = none).
@export var invincibility_duration: float = 0.0
signal hurt(damage_amount: int)
var _invincible: bool = false
@onready var _iframes_timer: Timer = _build_timer()
func receive_hit(damage: int) -> void:
if _invincible:
return
hurt.emit(damage)
if health_component:
health_component.take_damage(damage)
if invincibility_duration > 0.0:
_invincible = true
_iframes_timer.start(invincibility_duration)
func _on_iframes_timeout() -> void:
_invincible = false
func _build_timer() -> Timer:
var t := Timer.new()
t.one_shot = true
t.timeout.connect(_on_iframes_timeout)
add_child(t)
return t
C#(HurtboxComponent.cs)
using Godot;
public partial class HurtboxComponent : Area2D
{
/// <summary>Reference to the HealthComponent on the same entity.</summary>
[Export] public HealthComponent HealthComponent { get; set; }
/// <summary>Invincibility frame duration in seconds (0 = none).</summary>
[Export] public float InvincibilityDuration { get; set; } = 0f;
[Signal] public delegate void HurtEventHandler(int damageAmount);
private bool _invincible;
private Timer _iframesTimer;
public override void _Ready()
{
_iframesTimer = new Timer { OneShot = true };
_iframesTimer.Timeout += OnIframesTimeout;
AddChild(_iframesTimer);
}
public void ReceiveHit(int damage)
{
if (_invincible) return;
EmitSignal(SignalName.Hurt, damage);
HealthComponent?.TakeDamage(damage);
if (InvincibilityDuration > 0f)
{
_invincible = true;
_iframesTimer.Start(InvincibilityDuration);
}
}
private void OnIframesTimeout() => _invincible = false;
}
6. コンポーネント間の通信
コンポーネントから兄弟ノードのメソッドを直接呼び出してはいけません。疎結合を保つために、シグナルを使います。
┌─────────────────────────────────────────────────────┐
│ Entity (CharacterBody2D) │
│ │
│ ┌──────────────┐ hit(hurtbox) │
│ │ HitboxComponent ──────────────────────────────┐ │
│ └──────────────┘ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ HurtboxComponent │ │
│ │ receive_hit(dmg) │ │
│ │ ──── calls ──────► │ │
│ │ HealthComponent │ │
│ │ .take_damage(dmg) │ │
│ └────────┬────────────┘ │
│ │ │
│ health_changed / died │
│ │ │
│ ┌────────▼────────────┐ │
│ │ HealthComponent │ │
│ │ emits: died │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────┘
処理の流れ:
HitboxComponentはarea_enteredを通じて、重なっているHurtboxComponentを検出します。hit(target_hurtbox)を発行し(サウンドの再生など、エンティティ独自の処理に使用)、target_hurtbox.receive_hit(damage)を呼び出します。コンポーネント間の呼び出しはこれだけで、兄弟ノードではなく、ヒットボックスの直接的なインターフェースを対象にしています。HurtboxComponent.receive_hit()はアニメーションや VFX 用にhurt(damage_amount)を発行し、その後、明示的に接続された参照を使ってhealth_component.take_damage(damage)を呼び出します。HealthComponent.take_damage()は HP を更新し、health_changedまたはdiedを発行します。リスナー(UI、GameManager など)は、戦闘コンポーネントに触れることなく、それらのシグナルに接続します。
7. コンポーネントの接続
優先順位順に、3つのパターンがあります。
@export ノード参照 — 最も柔軟で、シーンツリー全体で機能します
# hurtbox_component.gd
@export var health_component: HealthComponent
# Inspector: drag the HealthComponent node into the slot.
注意点:
@exportノード参照は、エディターのインスペクターで接続します。シーンをプログラムで構築する場合、参照は何も割り当てられず、実行時には null になります。その場合は、親の_ready()で明示的に接続してください。hurtbox.health_component = health_component
@onready の直接の子 — コンポーネントが既知の子である場合にシンプルです
# enemy.gd
@onready var health: HealthComponent = $HealthComponent
@onready var hurtbox: HurtboxComponent = $HurtboxComponent
get_node パターン — パスが動的またはオプションの場合
func _ready() -> void:
var health := get_node_or_null("HealthComponent") as HealthComponent
if health:
health.died.connect(_on_died)
接続するノードがツリー内の別の場所にある場合は、
@exportを優先してください。常に存在する直接の子には@onreadyを優先してください。コンポーネントがオプションの場合は、get_node_or_nullを使用してください。
C# での同等の実装
// Pattern 1: [Export] property — drag-and-drop in the Inspector.
public partial class HurtboxComponent : Area3D
{
[Export] public HealthComponent Health { get; set; }
}
// Pattern 2: GetNode<T> for a known child path (equivalent to @onready var x := $Path).
public partial class Enemy : CharacterBody3D
{
private HealthComponent _health;
private HurtboxComponent _hurtbox;
public override void _Ready()
{
_health = GetNode<HealthComponent>("HealthComponent");
_hurtbox = GetNode<HurtboxComponent>("HurtboxComponent");
_health.Died += QueueFree;
}
}
// Pattern 3: GetNodeOrNull<T> when the component is optional (equivalent to get_node_or_null).
public partial class Pickup : Node3D
{
public override void _Ready()
{
var health = GetNodeOrNull<HealthComponent>("HealthComponent");
if (health != null)
health.Died += OnDied;
}
private void OnDied() { /* ... */ }
}
8. 実行時のコンポーネントの検索
静的ユーティリティを使用して、任意のエンティティ上にある指定型の最初のコンポーネントを検索します。これにより、異なるエンティティシーン間でノード名をハードコーディングせずに済みます。
GDScript(component_utils.gd)
class_name ComponentUtils
## Returns the first child of [param entity] that is an instance of [param component_type],
## or null if none is found.
static func get_component(entity: Node, component_type: GDScript) -> Node:
for child in entity.get_children():
if is_instance_of(child, component_type):
return child
return null
## Example usage:
## var health := ComponentUtils.get_component(enemy, HealthComponent) as HealthComponent
## if health:
## health.take_damage(5)
C#(ComponentUtils.cs)
using Godot;
public static class ComponentUtils
{
/// <summary>
/// Returns the first child of <paramref name="entity"/> that is of type
/// <typeparamref name="T"/>, or null if none is found.
/// </summary>
public static T GetComponent<T>(Node entity) where T : Node
{
foreach (var child in entity.GetChildren())
{
if (child is T component)
return component;
}
return null;
}
}
// Example usage:
// var health = ComponentUtils.GetComponent<HealthComponent>(enemy);
// health?.TakeDamage(5);
9. 実装チェックリスト
- 各コンポーネントは独自の
.tscnシーンとして保存し、インスタンス化して再利用する - コンポーネントはシグナルを介して通信し、
get_parent().get_node("Sibling")の呼び出しは使用しない - コンポーネントスクリプト内で、兄弟ノードに直接アクセスしない
- 調整可能な値(
damage、max_health、cooldown_duration)はすべて@exportにする - 各コンポーネントを単独の最小テストシーンに追加してテストできる