关于 component-system
面向 Godot 4.3+ 的游戏开发技能,介绍如何用小型可复用组件组织行为,避免深层继承链。提供 GDScript 和 C# 的组件设计、通信、配置与查找模式。
主要功能
- 01单一职责组件
- 02基于信号的通信
- 03生命值、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+ 中的组件系统
通过组合构建行为。将小而专注的组件附加到任意实体,而不是沿着继承链逐层扩展。所有示例均面向 Godot 4.3+,不使用已弃用的 API。
相关技能: scene-organization 用于场景树组合,event-bus 用于解耦组件通信,resource-pattern 用于数据驱动的组件配置,physics-system 用于 Area2D/3D 重叠检测和碰撞形状,ability-system 用于基于此模式构建的 AbilityComponent 示例。
1. 为什么使用组件
| 继承的弊端 | 组件的解决方式 |
|---|---|
| 继承链过深且脆弱——修改一个类,就可能破坏许多其他类 | 每个组件都是职责单一的独立场景 |
| 要在不相关的实体间共享行为,必须使用不合适的基类 | 将组件添加到任何需要该行为的实体上 |
| 添加新的组合意味着要创建新的子类 | 在场景层级自由组合组件 |
主要优势:
- 在实体间复用——
HealthComponent无需修改代码,就能用于玩家、敌人、可破坏的箱子或 Boss。 - 职责分离——伤害检测、生命值追踪和状态动画各自位于独立文件中,便于局部调试。
- 自由组合行为——可分别为敌人添加
HitboxComponent和PatrolComponent。移除其中一个不会影响另一个。
2. 组件设计规则
- 每个组件只承担一项职责。 如果你发现自己给它起名为
HealthAndShieldAndRegenComponent,就应该拆分它。 - 通过信号通信,不要直接访问同级节点。 组件不得调用
get_parent().get_node("SiblingComponent")。应改为发出信号。 - 尽可能保持无状态。 优先根据输入和
@export配置推导状态,而不是存储可变状态。确实需要状态时,应将其设为私有。 - 所有配置都使用
@export。 伤害量、冷却时长和图层掩码应在检查器中配置,而不是硬编码为常量。
3. 常用组件
| 组件 | 用途 | 关键信号 |
|---|---|---|
HealthComponent | 追踪当前和最大 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()发出hurt(damage_amount),用于动画和视觉特效,然后调用其显式连接的引用health_component.take_damage(damage)。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