725 lines
17 KiB
Markdown
725 lines
17 KiB
Markdown
# 组件系统
|
||
|
||
在 ECS 架构中,组件(Component)是数据和行为的载体。组件定义了实体具有的属性和功能,是 ECS 架构的核心构建块。
|
||
|
||
## 基本概念
|
||
|
||
组件是继承自 `Component` 抽象基类的具体类,用于:
|
||
- 存储实体的数据(如位置、速度、健康值等)
|
||
- 定义与数据相关的行为方法
|
||
- 提供生命周期回调钩子
|
||
- 支持序列化和调试
|
||
|
||
## 创建组件
|
||
|
||
### 基础组件定义
|
||
|
||
```typescript
|
||
import { Component, ECSComponent } from '@esengine/esengine';
|
||
|
||
@ECSComponent('Position')
|
||
class Position extends Component {
|
||
x: number = 0;
|
||
y: number = 0;
|
||
|
||
constructor(x: number = 0, y: number = 0) {
|
||
super();
|
||
this.x = x;
|
||
this.y = y;
|
||
}
|
||
}
|
||
|
||
@ECSComponent('Health')
|
||
class Health extends Component {
|
||
current: number;
|
||
max: number;
|
||
|
||
constructor(max: number = 100) {
|
||
super();
|
||
this.max = max;
|
||
this.current = max;
|
||
}
|
||
|
||
// 组件可以包含行为方法
|
||
takeDamage(damage: number): void {
|
||
this.current = Math.max(0, this.current - damage);
|
||
}
|
||
|
||
heal(amount: number): void {
|
||
this.current = Math.min(this.max, this.current + amount);
|
||
}
|
||
|
||
isDead(): boolean {
|
||
return this.current <= 0;
|
||
}
|
||
}
|
||
```
|
||
|
||
### @ECSComponent 装饰器
|
||
|
||
`@ECSComponent` 是组件类必须使用的装饰器,它为组件提供了类型标识和元数据管理。
|
||
|
||
#### 为什么必须使用
|
||
|
||
| 功能 | 说明 |
|
||
|------|------|
|
||
| **类型识别** | 提供稳定的类型名称,代码混淆后仍能正确识别 |
|
||
| **序列化支持** | 序列化/反序列化时使用该名称作为类型标识 |
|
||
| **组件注册** | 自动注册到 ComponentRegistry,分配唯一的位掩码 |
|
||
| **调试支持** | 在调试工具和日志中显示可读的组件名称 |
|
||
|
||
#### 基本语法
|
||
|
||
```typescript
|
||
@ECSComponent(typeName: string)
|
||
```
|
||
|
||
- `typeName`: 组件的类型名称,建议使用与类名相同或相近的名称
|
||
|
||
#### 使用示例
|
||
|
||
```typescript
|
||
// ✅ 正确的用法
|
||
@ECSComponent('Velocity')
|
||
class Velocity extends Component {
|
||
dx: number = 0;
|
||
dy: number = 0;
|
||
}
|
||
|
||
// ✅ 推荐:类型名与类名保持一致
|
||
@ECSComponent('PlayerController')
|
||
class PlayerController extends Component {
|
||
speed: number = 5;
|
||
}
|
||
|
||
// ❌ 错误的用法 - 没有装饰器
|
||
class BadComponent extends Component {
|
||
// 这样定义的组件可能在生产环境出现问题:
|
||
// 1. 代码压缩后类名变化,无法正确序列化
|
||
// 2. 组件未注册到框架,查询和匹配可能失效
|
||
}
|
||
```
|
||
|
||
#### 与 @Serializable 配合使用
|
||
|
||
当组件需要支持序列化时,`@ECSComponent` 和 `@Serializable` 需要一起使用:
|
||
|
||
```typescript
|
||
import { Component, ECSComponent, Serializable, Serialize } from '@esengine/esengine';
|
||
|
||
@ECSComponent('Player')
|
||
@Serializable({ version: 1 })
|
||
class PlayerComponent extends Component {
|
||
@Serialize()
|
||
name: string = '';
|
||
|
||
@Serialize()
|
||
level: number = 1;
|
||
|
||
// 不使用 @Serialize() 的字段不会被序列化
|
||
private _cachedData: any = null;
|
||
}
|
||
```
|
||
|
||
> **注意**:`@ECSComponent` 的 `typeName` 和 `@Serializable` 的 `typeId` 可以不同。如果 `@Serializable` 没有指定 `typeId`,则默认使用 `@ECSComponent` 的 `typeName`。
|
||
|
||
#### 组件类型名的唯一性
|
||
|
||
每个组件的类型名应该是唯一的:
|
||
|
||
```typescript
|
||
// ❌ 错误:两个组件使用相同的类型名
|
||
@ECSComponent('Health')
|
||
class HealthComponent extends Component { }
|
||
|
||
@ECSComponent('Health') // 冲突!
|
||
class EnemyHealthComponent extends Component { }
|
||
|
||
// ✅ 正确:使用不同的类型名
|
||
@ECSComponent('PlayerHealth')
|
||
class PlayerHealthComponent extends Component { }
|
||
|
||
@ECSComponent('EnemyHealth')
|
||
class EnemyHealthComponent extends Component { }
|
||
```
|
||
|
||
## 组件生命周期
|
||
|
||
组件提供了生命周期钩子,可以重写来执行特定的逻辑:
|
||
|
||
```typescript
|
||
@ECSComponent('ExampleComponent')
|
||
class ExampleComponent extends Component {
|
||
private resource: SomeResource | null = null;
|
||
|
||
/**
|
||
* 组件被添加到实体时调用
|
||
* 用于初始化资源、建立引用等
|
||
*/
|
||
onAddedToEntity(): void {
|
||
console.log(`组件 ${this.constructor.name} 已添加,实体ID: ${this.entityId}`);
|
||
this.resource = new SomeResource();
|
||
}
|
||
|
||
/**
|
||
* 组件从实体移除时调用
|
||
* 用于清理资源、断开引用等
|
||
*/
|
||
onRemovedFromEntity(): void {
|
||
console.log(`组件 ${this.constructor.name} 已移除`);
|
||
if (this.resource) {
|
||
this.resource.cleanup();
|
||
this.resource = null;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## 组件与实体的关系
|
||
|
||
组件存储了所属实体的ID (`entityId`),而不是直接引用实体对象。这是ECS数据导向设计的体现,避免了循环引用。
|
||
|
||
在实际使用中,**应该在 System 中处理实体和组件的交互**,而不是在组件内部:
|
||
|
||
```typescript
|
||
@ECSComponent('Health')
|
||
class Health extends Component {
|
||
current: number;
|
||
max: number;
|
||
|
||
constructor(max: number = 100) {
|
||
super();
|
||
this.max = max;
|
||
this.current = max;
|
||
}
|
||
|
||
isDead(): boolean {
|
||
return this.current <= 0;
|
||
}
|
||
}
|
||
|
||
@ECSComponent('Damage')
|
||
class Damage extends Component {
|
||
value: number;
|
||
|
||
constructor(value: number) {
|
||
super();
|
||
this.value = value;
|
||
}
|
||
}
|
||
|
||
// 推荐:在 System 中处理逻辑
|
||
class DamageSystem extends EntitySystem {
|
||
constructor() {
|
||
super(new Matcher().all(Health, Damage));
|
||
}
|
||
|
||
process(entities: readonly Entity[]): void {
|
||
for (const entity of entities) {
|
||
const health = entity.getComponent(Health)!;
|
||
const damage = entity.getComponent(Damage)!;
|
||
|
||
health.current -= damage.value;
|
||
|
||
if (health.isDead()) {
|
||
entity.destroy();
|
||
}
|
||
|
||
// 应用伤害后移除 Damage 组件
|
||
entity.removeComponent(damage);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
## 组件属性
|
||
|
||
每个组件都有一些内置属性:
|
||
|
||
```typescript
|
||
@ECSComponent('ExampleComponent')
|
||
class ExampleComponent extends Component {
|
||
someData: string = "example";
|
||
|
||
onAddedToEntity(): void {
|
||
console.log(`组件ID: ${this.id}`); // 唯一的组件ID
|
||
console.log(`所属实体ID: ${this.entityId}`); // 所属实体的ID
|
||
}
|
||
}
|
||
```
|
||
|
||
如果需要访问实体对象,应该在 System 中进行:
|
||
|
||
```typescript
|
||
class ExampleSystem extends EntitySystem {
|
||
constructor() {
|
||
super(new Matcher().all(ExampleComponent));
|
||
}
|
||
|
||
process(entities: readonly Entity[]): void {
|
||
for (const entity of entities) {
|
||
const comp = entity.getComponent(ExampleComponent)!;
|
||
console.log(`实体名称: ${entity.name}`);
|
||
console.log(`组件数据: ${comp.someData}`);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
|
||
## 复杂组件示例
|
||
|
||
### 状态机组件
|
||
|
||
```typescript
|
||
enum EntityState {
|
||
Idle,
|
||
Moving,
|
||
Attacking,
|
||
Dead
|
||
}
|
||
|
||
@ECSComponent('StateMachine')
|
||
class StateMachine extends Component {
|
||
private _currentState: EntityState = EntityState.Idle;
|
||
private _previousState: EntityState = EntityState.Idle;
|
||
private _stateTimer: number = 0;
|
||
|
||
get currentState(): EntityState {
|
||
return this._currentState;
|
||
}
|
||
|
||
get previousState(): EntityState {
|
||
return this._previousState;
|
||
}
|
||
|
||
get stateTimer(): number {
|
||
return this._stateTimer;
|
||
}
|
||
|
||
changeState(newState: EntityState): void {
|
||
if (this._currentState !== newState) {
|
||
this._previousState = this._currentState;
|
||
this._currentState = newState;
|
||
this._stateTimer = 0;
|
||
}
|
||
}
|
||
|
||
updateTimer(deltaTime: number): void {
|
||
this._stateTimer += deltaTime;
|
||
}
|
||
|
||
isInState(state: EntityState): boolean {
|
||
return this._currentState === state;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 配置数据组件
|
||
|
||
```typescript
|
||
interface WeaponData {
|
||
damage: number;
|
||
range: number;
|
||
fireRate: number;
|
||
ammo: number;
|
||
}
|
||
|
||
@ECSComponent('WeaponConfig')
|
||
class WeaponConfig extends Component {
|
||
data: WeaponData;
|
||
|
||
constructor(weaponData: WeaponData) {
|
||
super();
|
||
this.data = { ...weaponData }; // 深拷贝避免共享引用
|
||
}
|
||
|
||
// 提供便捷的访问方法
|
||
getDamage(): number {
|
||
return this.data.damage;
|
||
}
|
||
|
||
canFire(): boolean {
|
||
return this.data.ammo > 0;
|
||
}
|
||
|
||
consumeAmmo(): boolean {
|
||
if (this.data.ammo > 0) {
|
||
this.data.ammo--;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
## 最佳实践
|
||
|
||
### 1. 保持组件简单
|
||
|
||
```typescript
|
||
// 好的组件设计 - 单一职责
|
||
@ECSComponent('Position')
|
||
class Position extends Component {
|
||
x: number = 0;
|
||
y: number = 0;
|
||
}
|
||
|
||
@ECSComponent('Velocity')
|
||
class Velocity extends Component {
|
||
dx: number = 0;
|
||
dy: number = 0;
|
||
}
|
||
|
||
// 避免的组件设计 - 职责过多
|
||
@ECSComponent('GameObject')
|
||
class GameObject extends Component {
|
||
x: number;
|
||
y: number;
|
||
dx: number;
|
||
dy: number;
|
||
health: number;
|
||
damage: number;
|
||
sprite: string;
|
||
// 太多不相关的属性
|
||
}
|
||
```
|
||
|
||
### 2. 使用构造函数初始化
|
||
|
||
```typescript
|
||
@ECSComponent('Transform')
|
||
class Transform extends Component {
|
||
x: number;
|
||
y: number;
|
||
rotation: number;
|
||
scale: number;
|
||
|
||
constructor(x = 0, y = 0, rotation = 0, scale = 1) {
|
||
super();
|
||
this.x = x;
|
||
this.y = y;
|
||
this.rotation = rotation;
|
||
this.scale = scale;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3. 明确的类型定义
|
||
|
||
```typescript
|
||
interface InventoryItem {
|
||
id: string;
|
||
name: string;
|
||
quantity: number;
|
||
type: 'weapon' | 'consumable' | 'misc';
|
||
}
|
||
|
||
@ECSComponent('Inventory')
|
||
class Inventory extends Component {
|
||
items: InventoryItem[] = [];
|
||
maxSlots: number;
|
||
|
||
constructor(maxSlots: number = 20) {
|
||
super();
|
||
this.maxSlots = maxSlots;
|
||
}
|
||
|
||
addItem(item: InventoryItem): boolean {
|
||
if (this.items.length < this.maxSlots) {
|
||
this.items.push(item);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
removeItem(itemId: string): InventoryItem | null {
|
||
const index = this.items.findIndex(item => item.id === itemId);
|
||
if (index !== -1) {
|
||
return this.items.splice(index, 1)[0];
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4. 引用其他实体
|
||
|
||
当组件需要关联其他实体时(如父子关系、跟随目标等),**推荐方式是存储实体ID**,然后在 System 中查找:
|
||
|
||
```typescript
|
||
@ECSComponent('Follower')
|
||
class Follower extends Component {
|
||
targetId: number;
|
||
followDistance: number = 50;
|
||
|
||
constructor(targetId: number) {
|
||
super();
|
||
this.targetId = targetId;
|
||
}
|
||
}
|
||
|
||
// 在 System 中查找目标实体并处理逻辑
|
||
class FollowerSystem extends EntitySystem {
|
||
constructor() {
|
||
super(new Matcher().all(Follower, Position));
|
||
}
|
||
|
||
process(entities: readonly Entity[]): void {
|
||
for (const entity of entities) {
|
||
const follower = entity.getComponent(Follower)!;
|
||
const position = entity.getComponent(Position)!;
|
||
|
||
// 通过场景查找目标实体
|
||
const target = entity.scene?.findEntityById(follower.targetId);
|
||
if (target) {
|
||
const targetPos = target.getComponent(Position);
|
||
if (targetPos) {
|
||
// 跟随逻辑
|
||
const dx = targetPos.x - position.x;
|
||
const dy = targetPos.y - position.y;
|
||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||
|
||
if (distance > follower.followDistance) {
|
||
// 移动靠近目标
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
这种方式的优势:
|
||
- 组件保持简单,只存储基本数据类型
|
||
- 符合数据导向设计
|
||
- 在 System 中统一处理查找和逻辑
|
||
- 易于理解和维护
|
||
|
||
**避免在组件中直接存储实体引用**:
|
||
|
||
```typescript
|
||
// 错误示范:直接存储实体引用
|
||
@ECSComponent('BadFollower')
|
||
class BadFollower extends Component {
|
||
target: Entity; // 实体销毁后仍持有引用,可能导致内存泄漏
|
||
}
|
||
```
|
||
|
||
## 高级特性
|
||
|
||
### EntityRef 装饰器 - 自动引用追踪
|
||
|
||
框架提供了 `@EntityRef` 装饰器用于**特殊场景**下安全地存储实体引用。这是一个高级特性,一般情况下推荐使用存储ID的方式。
|
||
|
||
#### 什么时候需要 EntityRef?
|
||
|
||
在以下场景中,`@EntityRef` 可以简化代码:
|
||
|
||
1. **父子关系**: 需要在组件中直接访问父实体或子实体
|
||
2. **复杂关联**: 实体之间有多个引用关系
|
||
3. **频繁访问**: 需要在多处访问引用的实体,使用ID查找会有性能开销
|
||
|
||
#### 核心特性
|
||
|
||
`@EntityRef` 装饰器通过 **ReferenceTracker** 自动追踪引用关系:
|
||
|
||
- 当被引用的实体销毁时,所有指向它的 `@EntityRef` 属性自动设为 `null`
|
||
- 防止跨场景引用(会输出警告并拒绝设置)
|
||
- 防止引用已销毁的实体(会输出警告并设为 `null`)
|
||
- 使用 WeakRef 避免内存泄漏(自动GC支持)
|
||
- 组件移除时自动清理引用注册
|
||
|
||
#### 基本用法
|
||
|
||
```typescript
|
||
import { Component, ECSComponent, EntityRef, Entity } from '@esengine/esengine';
|
||
|
||
@ECSComponent('Parent')
|
||
class ParentComponent extends Component {
|
||
@EntityRef()
|
||
parent: Entity | null = null;
|
||
}
|
||
|
||
// 使用示例
|
||
const scene = new Scene();
|
||
const parent = scene.createEntity('Parent');
|
||
const child = scene.createEntity('Child');
|
||
|
||
const comp = child.addComponent(new ParentComponent());
|
||
comp.parent = parent;
|
||
|
||
console.log(comp.parent); // Entity { name: 'Parent' }
|
||
|
||
// 当 parent 被销毁时,comp.parent 自动变为 null
|
||
parent.destroy();
|
||
console.log(comp.parent); // null
|
||
```
|
||
|
||
#### 多个引用属性
|
||
|
||
一个组件可以有多个 `@EntityRef` 属性:
|
||
|
||
```typescript
|
||
@ECSComponent('Combat')
|
||
class CombatComponent extends Component {
|
||
@EntityRef()
|
||
target: Entity | null = null;
|
||
|
||
@EntityRef()
|
||
ally: Entity | null = null;
|
||
|
||
@EntityRef()
|
||
lastAttacker: Entity | null = null;
|
||
}
|
||
|
||
// 使用示例
|
||
const player = scene.createEntity('Player');
|
||
const enemy = scene.createEntity('Enemy');
|
||
const npc = scene.createEntity('NPC');
|
||
|
||
const combat = player.addComponent(new CombatComponent());
|
||
combat.target = enemy;
|
||
combat.ally = npc;
|
||
|
||
// enemy 销毁后,只有 target 变为 null,ally 仍然有效
|
||
enemy.destroy();
|
||
console.log(combat.target); // null
|
||
console.log(combat.ally); // Entity { name: 'NPC' }
|
||
```
|
||
|
||
#### 安全检查
|
||
|
||
`@EntityRef` 提供了多重安全检查:
|
||
|
||
```typescript
|
||
const scene1 = new Scene();
|
||
const scene2 = new Scene();
|
||
|
||
const entity1 = scene1.createEntity('Entity1');
|
||
const entity2 = scene2.createEntity('Entity2');
|
||
|
||
const comp = entity1.addComponent(new ParentComponent());
|
||
|
||
// 跨场景引用会失败
|
||
comp.parent = entity2; // 输出错误日志,comp.parent 为 null
|
||
console.log(comp.parent); // null
|
||
|
||
// 引用已销毁的实体会失败
|
||
const entity3 = scene1.createEntity('Entity3');
|
||
entity3.destroy();
|
||
comp.parent = entity3; // 输出警告日志,comp.parent 为 null
|
||
console.log(comp.parent); // null
|
||
```
|
||
|
||
#### 实现原理
|
||
|
||
`@EntityRef` 使用以下机制实现自动引用追踪:
|
||
|
||
1. **ReferenceTracker**: Scene 持有一个引用追踪器,记录所有实体引用关系
|
||
2. **WeakRef**: 使用弱引用存储组件,避免循环引用导致内存泄漏
|
||
3. **属性拦截**: 通过 `Object.defineProperty` 拦截 getter/setter
|
||
4. **自动清理**: 实体销毁时,ReferenceTracker 遍历所有引用并设为 null
|
||
|
||
```typescript
|
||
// 简化的实现原理
|
||
class ReferenceTracker {
|
||
// entityId -> 引用该实体的所有组件记录
|
||
private _references: Map<number, Set<{ component: WeakRef<Component>, propertyKey: string }>>;
|
||
|
||
// 实体销毁时调用
|
||
clearReferencesTo(entityId: number): void {
|
||
const records = this._references.get(entityId);
|
||
if (records) {
|
||
for (const record of records) {
|
||
const component = record.component.deref();
|
||
if (component) {
|
||
// 将组件的引用属性设为 null
|
||
(component as any)[record.propertyKey] = null;
|
||
}
|
||
}
|
||
this._references.delete(entityId);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 性能考虑
|
||
|
||
`@EntityRef` 会带来一些性能开销:
|
||
|
||
- **写入开销**: 每次设置引用时需要更新 ReferenceTracker
|
||
- **内存开销**: ReferenceTracker 需要维护引用映射表
|
||
- **销毁开销**: 实体销毁时需要遍历所有引用并清理
|
||
|
||
对于大多数场景,这些开销是可以接受的。但如果有**大量实体和频繁的引用变更**,存储ID可能更高效。
|
||
|
||
#### 最佳实践
|
||
|
||
```typescript
|
||
// 推荐:适合使用 @EntityRef 的场景 - 父子关系
|
||
@ECSComponent('Transform')
|
||
class Transform extends Component {
|
||
@EntityRef()
|
||
parent: Entity | null = null;
|
||
|
||
position: { x: number, y: number } = { x: 0, y: 0 };
|
||
|
||
// 可以直接访问父实体的组件
|
||
getWorldPosition(): { x: number, y: number } {
|
||
if (!this.parent) {
|
||
return { ...this.position };
|
||
}
|
||
|
||
const parentTransform = this.parent.getComponent(Transform);
|
||
if (parentTransform) {
|
||
const parentPos = parentTransform.getWorldPosition();
|
||
return {
|
||
x: parentPos.x + this.position.x,
|
||
y: parentPos.y + this.position.y
|
||
};
|
||
}
|
||
|
||
return { ...this.position };
|
||
}
|
||
}
|
||
|
||
// 不推荐:不适合使用 @EntityRef 的场景 - 大量动态目标
|
||
@ECSComponent('AITarget')
|
||
class AITarget extends Component {
|
||
@EntityRef()
|
||
target: Entity | null = null; // 如果目标频繁变化,用ID更好
|
||
|
||
updateCooldown: number = 0;
|
||
}
|
||
|
||
// 推荐:这种场景用ID更好
|
||
@ECSComponent('AITarget')
|
||
class AITargetBetter extends Component {
|
||
targetId: number | null = null; // 存储ID
|
||
updateCooldown: number = 0;
|
||
}
|
||
```
|
||
|
||
#### 调试支持
|
||
|
||
ReferenceTracker 提供了调试接口:
|
||
|
||
```typescript
|
||
// 查看某个实体被哪些组件引用
|
||
const references = scene.referenceTracker.getReferencesTo(entity.id);
|
||
console.log(`实体 ${entity.name} 被 ${references.length} 个组件引用`);
|
||
|
||
// 获取完整的调试信息
|
||
const debugInfo = scene.referenceTracker.getDebugInfo();
|
||
console.log(debugInfo);
|
||
```
|
||
|
||
#### 总结
|
||
|
||
- **推荐做法**: 大部分情况使用存储ID + System查找的方式
|
||
- **EntityRef 适用场景**: 父子关系、复杂关联、组件内需要直接访问引用实体的场景
|
||
- **核心优势**: 自动清理、防止悬空引用、代码更简洁
|
||
- **注意事项**: 有性能开销,不适合大量动态引用的场景
|
||
|
||
组件是 ECS 架构的数据载体,正确设计组件能让你的游戏代码更模块化、可维护和高性能。 |