mcpField
C
component-system

component-system

github.com/jame581開発889173 回インストール4 日前に更新
リポジトリ

component-system について

Godot 4.3以降のゲーム開発で、深い継承階層ではなく小さく再利用できるコンポーネントで振る舞いを構成するスキルです。GDScriptとC#で設計、通信、設定、検索のパターンを紹介します。

主な機能

  1. 01単一責任のコンポーネント
  2. 02シグナルによる通信
  3. 03Health、Hitbox、Hurtboxのパターン
  4. 04インスペクターでの設定
  5. 05コンポーネントの接続と検索

活用シーン

  1. 01プレイヤーと敵の間で振る舞いを再利用
  2. 02ダメージ検出と体力管理の分離
  3. 03シーン内のエンティティに任意の振る舞いを組み合わせ
  4. 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つのコンポーネントにつき、責務は1つにします。 HealthAndShieldAndRegenComponent のような名前を付けようとしている場合は、分割してください。
  2. 直接の兄弟ノードへのアクセスではなく、シグナルで通信します。 コンポーネントから get_parent().get_node("SiblingComponent") を呼び出してはいけません。代わりにシグナルを発行します。
  3. 可能な限りステートレスにします。 可変状態を保存するより、入力や @export 設定から状態を導出してください。状態が必要な場合は、非公開に保ちます。
  4. すべての設定に @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        │ │
│                              └─────────────────────┘ │
└─────────────────────────────────────────────────────┘

処理の流れ:

  1. HitboxComponent は area_entered を通じて、重なっている HurtboxComponent を検出します。
  2. hit(target_hurtbox) を発行し(サウンドの再生など、エンティティ独自の処理に使用)、target_hurtbox.receive_hit(damage) を呼び出します。コンポーネント間の呼び出しはこれだけで、兄弟ノードではなく、ヒットボックスの直接的なインターフェースを対象にしています。
  3. HurtboxComponent.receive_hit() はアニメーションや VFX 用に hurt(damage_amount) を発行し、その後、明示的に接続された参照を使って health_component.take_damage(damage) を呼び出します。
  4. 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 にする
  • 各コンポーネントを単独の最小テストシーンに追加してテストできる

コメント