mcpField
C
component-system

component-system

github.com/jame581开发889173 次安装4 天前更新
代码仓库

关于 component-system

面向 Godot 4.3+ 的游戏开发技能,介绍如何用小型可复用组件组织行为,避免深层继承链。提供 GDScript 和 C# 的组件设计、通信、配置与查找模式。

主要功能

  1. 01单一职责组件
  2. 02基于信号的通信
  3. 03生命值、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+ 中的组件系统

通过组合构建行为。将小而专注的组件附加到任意实体,而不是沿着继承链逐层扩展。所有示例均面向 Godot 4.3+,不使用已弃用的 API。

相关技能: scene-organization 用于场景树组合,event-bus 用于解耦组件通信,resource-pattern 用于数据驱动的组件配置,physics-system 用于 Area2D/3D 重叠检测和碰撞形状,ability-system 用于基于此模式构建的 AbilityComponent 示例。


1. 为什么使用组件

继承的弊端组件的解决方式
继承链过深且脆弱——修改一个类,就可能破坏许多其他类每个组件都是职责单一的独立场景
要在不相关的实体间共享行为,必须使用不合适的基类将组件添加到任何需要该行为的实体上
添加新的组合意味着要创建新的子类在场景层级自由组合组件

主要优势:

  • 在实体间复用——HealthComponent 无需修改代码,就能用于玩家、敌人、可破坏的箱子或 Boss。
  • 职责分离——伤害检测、生命值追踪和状态动画各自位于独立文件中,便于局部调试。
  • 自由组合行为——可分别为敌人添加 HitboxComponent 和 PatrolComponent。移除其中一个不会影响另一个。

2. 组件设计规则

  1. 每个组件只承担一项职责。 如果你发现自己给它起名为 HealthAndShieldAndRegenComponent,就应该拆分它。
  2. 通过信号通信,不要直接访问同级节点。 组件不得调用 get_parent().get_node("SiblingComponent")。应改为发出信号。
  3. 尽可能保持无状态。 优先根据输入和 @export 配置推导状态,而不是存储可变状态。确实需要状态时,应将其设为私有。
  4. 所有配置都使用 @export。 伤害量、冷却时长和图层掩码应在检查器中配置,而不是硬编码为常量。

3. 常用组件

组件用途关键信号
HealthComponent追踪当前和最大 HP,处理伤害与治疗health_changed(current, maximum)、died
HitboxComponent检测重叠的受击框并触发伤害hit(target_hurtbox)
HurtboxComponent接收命中,并将伤害传递给 HealthComponenthurt(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() 发出 hurt(damage_amount),用于动画和视觉特效,然后调用其显式连接的引用 health_component.take_damage(damage)。
  4. HealthComponent.take_damage() 更新 HP,并发出 health_changed 或 died。监听方(UI、GameManager 等)连接这些信号,无需接触战斗组件。

7. 连接组件

按优先级排列的三种模式:

@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
  • 每个组件都可以通过将其单独附加到最简测试场景来进行测试

评论

支持 Markdown