735 lines
16 KiB
Markdown
735 lines
16 KiB
Markdown
# 自定义动作组件
|
||
|
||
本教程介绍如何为项目创建专用的动作组件,供策划在编辑器中使用。
|
||
|
||
## 为什么需要自定义组件?
|
||
|
||
ExecuteAction节点允许在编辑器中编写JavaScript代码,但这种方式存在以下问题:
|
||
|
||
- 策划不懂编程,无法编写代码
|
||
- 没有智能提示,容易出错
|
||
- 缺少类型检查,运行时才发现问题
|
||
- 代码分散在编辑器中,难以维护
|
||
|
||
**推荐做法**:程序员创建专用的动作组件类,策划只需配置参数。
|
||
|
||
## 基础结构
|
||
|
||
一个自定义动作组件的基本结构:
|
||
|
||
```typescript
|
||
import { Component, ECSComponent, Entity } from '@esengine/ecs-framework';
|
||
import { Serializable, Serialize } from '@esengine/ecs-framework';
|
||
import {
|
||
TaskStatus,
|
||
NodeType,
|
||
BlackboardComponent,
|
||
BehaviorNode,
|
||
BehaviorProperty
|
||
} from '@esengine/behavior-tree';
|
||
|
||
@BehaviorNode({
|
||
displayName: '动作名称', // 在编辑器中显示的名称
|
||
category: '分类', // 节点分类(如"战斗"、"移动")
|
||
type: NodeType.Action, // 使用内置类型
|
||
// 或使用自定义类型:
|
||
// type: 'custom-behavior', // 自定义节点类型
|
||
icon: 'IconName', // 图标名称(可选)
|
||
description: '动作描述', // 描述信息
|
||
color: '#FF5722' // 节点颜色(可选)
|
||
})
|
||
@ECSComponent('CustomActionName') // 组件名称
|
||
@Serializable({ version: 1 }) // 可序列化
|
||
export class CustomAction extends Component {
|
||
// 属性定义...
|
||
|
||
/**
|
||
* 执行方法
|
||
* 系统会自动调用此方法
|
||
*/
|
||
execute(entity: Entity, blackboard?: BlackboardComponent, deltaTime?: number): TaskStatus {
|
||
// 你的逻辑
|
||
return TaskStatus.Success;
|
||
}
|
||
}
|
||
```
|
||
|
||
## 自定义节点类型
|
||
|
||
除了使用内置的节点类型(`Action`、`Condition`、`Composite`、`Decorator`),你也可以定义自己的节点类型:
|
||
|
||
```typescript
|
||
@BehaviorNode({
|
||
displayName: 'AI决策',
|
||
category: '高级',
|
||
type: 'ai-decision', // 自定义类型
|
||
description: '使用机器学习进行决策',
|
||
color: '#00BCD4'
|
||
})
|
||
export class AIDecisionNode extends Component {
|
||
execute(...): TaskStatus {
|
||
// 自定义逻辑
|
||
return TaskStatus.Success;
|
||
}
|
||
}
|
||
```
|
||
|
||
自定义节点类型的好处:
|
||
- 可以创建特殊的执行逻辑
|
||
- 便于编辑器中分类和识别
|
||
- 支持项目特定的工作流
|
||
|
||
## 定义属性
|
||
|
||
使用 `@BehaviorProperty` 装饰器定义可配置的属性:
|
||
|
||
### 内置属性类型
|
||
|
||
框架提供了多种常用的属性类型:
|
||
|
||
#### 数值类型
|
||
|
||
```typescript
|
||
import { PropertyType } from '@esengine/behavior-tree';
|
||
|
||
@BehaviorProperty({
|
||
label: '伤害值',
|
||
type: PropertyType.Number, // 或直接使用 'number'
|
||
description: '造成的伤害',
|
||
min: 0,
|
||
max: 999,
|
||
step: 1
|
||
})
|
||
@Serialize()
|
||
damage: number = 10;
|
||
```
|
||
|
||
策划在编辑器中看到的是:
|
||
- 标签:"伤害值"
|
||
- 滑块:0-999,步长为1
|
||
- 默认值:10
|
||
|
||
#### 选择框类型
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '攻击类型',
|
||
type: PropertyType.Select,
|
||
description: '攻击方式',
|
||
options: [
|
||
{ label: '近战', value: 'melee' },
|
||
{ label: '远程', value: 'ranged' },
|
||
{ label: '魔法', value: 'magic' }
|
||
]
|
||
})
|
||
@Serialize()
|
||
attackType: string = 'melee';
|
||
```
|
||
|
||
策划看到的是下拉选择框,选项为:近战、远程、魔法
|
||
|
||
#### 布尔类型
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '是否循环',
|
||
type: PropertyType.Boolean,
|
||
description: '动画是否循环播放'
|
||
})
|
||
@Serialize()
|
||
loop: boolean = false;
|
||
```
|
||
|
||
策划看到的是复选框
|
||
|
||
#### 字符串类型
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '动画名称',
|
||
type: PropertyType.String,
|
||
description: '要播放的动画名称',
|
||
required: true
|
||
})
|
||
@Serialize()
|
||
animationName: string = '';
|
||
```
|
||
|
||
策划看到的是文本输入框,标记为必填
|
||
|
||
#### 黑板变量引用
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '目标位置变量',
|
||
type: PropertyType.Blackboard,
|
||
description: '黑板中存储目标位置的变量名'
|
||
})
|
||
@Serialize()
|
||
targetVariableName: string = 'targetPosition';
|
||
```
|
||
|
||
策划看到的是黑板变量选择器
|
||
|
||
#### 代码编辑器
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '配置(JSON)',
|
||
type: PropertyType.Code,
|
||
description: '配置数据,JSON格式'
|
||
})
|
||
@Serialize()
|
||
configJson: string = '{}';
|
||
```
|
||
|
||
策划看到的是代码编辑器
|
||
|
||
#### 资产引用
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '音效文件',
|
||
type: PropertyType.Asset,
|
||
description: '要播放的音效资产'
|
||
})
|
||
@Serialize()
|
||
soundAsset: string = '';
|
||
```
|
||
|
||
策划看到的是资产选择器
|
||
|
||
### 自定义属性渲染
|
||
|
||
你可以通过 `renderConfig` 配置自定义属性的渲染方式:
|
||
|
||
#### 使用自定义渲染器组件
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '颜色',
|
||
type: 'color',
|
||
description: '选择颜色',
|
||
renderConfig: {
|
||
component: 'ColorPicker', // 编辑器中的渲染器组件名
|
||
props: {
|
||
showAlpha: true, // 是否显示透明度
|
||
presets: [ // 预设颜色
|
||
'#FF0000',
|
||
'#00FF00',
|
||
'#0000FF'
|
||
]
|
||
}
|
||
}
|
||
})
|
||
@Serialize()
|
||
color: string = '#FFFFFF';
|
||
```
|
||
|
||
#### 使用曲线编辑器
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '动画曲线',
|
||
type: 'curve',
|
||
description: '编辑动画曲线',
|
||
renderConfig: {
|
||
component: 'CurveEditor',
|
||
props: {
|
||
min: 0,
|
||
max: 1,
|
||
defaultCurve: 'linear'
|
||
},
|
||
style: {
|
||
height: '200px'
|
||
}
|
||
}
|
||
})
|
||
@Serialize()
|
||
curve: string = '';
|
||
```
|
||
|
||
#### 使用项目特定的选择器
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '技能',
|
||
type: 'skill',
|
||
description: '选择技能',
|
||
renderConfig: {
|
||
component: 'SkillSelector', // 项目自定义的技能选择器
|
||
props: {
|
||
category: 'combat', // 只显示战斗技能
|
||
maxLevel: 10,
|
||
showIcon: true
|
||
}
|
||
}
|
||
})
|
||
@Serialize()
|
||
skillId: number = 0;
|
||
```
|
||
|
||
#### 使用自定义验证
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: 'IP地址',
|
||
type: PropertyType.String,
|
||
description: '输入IP地址',
|
||
validation: {
|
||
pattern: /^(\d{1,3}\.){3}\d{1,3}$/,
|
||
message: '请输入有效的IP地址'
|
||
}
|
||
})
|
||
@Serialize()
|
||
ipAddress: string = '127.0.0.1';
|
||
```
|
||
|
||
#### 使用资产浏览器
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '音效',
|
||
type: 'asset',
|
||
description: '选择音效文件',
|
||
renderConfig: {
|
||
component: 'AssetBrowser',
|
||
props: {
|
||
filter: ['mp3', 'wav', 'ogg'], // 只显示音频文件
|
||
basePath: 'assets/sounds' // 默认路径
|
||
}
|
||
}
|
||
})
|
||
@Serialize()
|
||
soundPath: string = '';
|
||
```
|
||
|
||
#### 使用滑块和输入框组合
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '音量',
|
||
type: PropertyType.Number,
|
||
min: 0,
|
||
max: 100,
|
||
renderConfig: {
|
||
component: 'SliderWithInput', // 滑块+输入框组合控件
|
||
props: {
|
||
showPercentage: true,
|
||
marks: { // 刻度标记
|
||
0: '静音',
|
||
50: '中等',
|
||
100: '最大'
|
||
}
|
||
}
|
||
}
|
||
})
|
||
@Serialize()
|
||
volume: number = 80;
|
||
```
|
||
|
||
### 渲染配置说明
|
||
|
||
`renderConfig` 对象支持以下字段:
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `component` | string | 渲染器组件名称(需在编辑器中注册) |
|
||
| `props` | object | 传递给渲染器的属性配置 |
|
||
| `className` | string | CSS类名 |
|
||
| `style` | object | 内联样式 |
|
||
|
||
编辑器会根据 `component` 查找对应的渲染器组件,并将 `props` 传递给它。
|
||
|
||
## 完整示例
|
||
|
||
### 示例1:攻击动作
|
||
|
||
```typescript
|
||
import { PropertyType } from '@esengine/behavior-tree';
|
||
|
||
@BehaviorNode({
|
||
displayName: '攻击目标',
|
||
category: '战斗',
|
||
type: NodeType.Action,
|
||
icon: 'Sword',
|
||
description: '对目标造成伤害',
|
||
color: '#FF5722'
|
||
})
|
||
@ECSComponent('AttackAction')
|
||
@Serializable({ version: 1 })
|
||
export class AttackAction extends Component {
|
||
@BehaviorProperty({
|
||
label: '伤害值',
|
||
type: PropertyType.Number,
|
||
min: 0,
|
||
max: 999
|
||
})
|
||
@Serialize()
|
||
damage: number = 10;
|
||
|
||
@BehaviorProperty({
|
||
label: '攻击类型',
|
||
type: PropertyType.Select,
|
||
options: [
|
||
{ label: '近战', value: 'melee' },
|
||
{ label: '远程', value: 'ranged' },
|
||
{ label: '魔法', value: 'magic' }
|
||
]
|
||
})
|
||
@Serialize()
|
||
attackType: string = 'melee';
|
||
|
||
execute(entity: Entity, blackboard?: BlackboardComponent): TaskStatus {
|
||
const target = blackboard?.getValue('target');
|
||
if (!target) {
|
||
return TaskStatus.Failure;
|
||
}
|
||
|
||
// 执行攻击逻辑
|
||
console.log(`使用${this.attackType}攻击,造成${this.damage}点伤害`);
|
||
|
||
// 触发事件让游戏逻辑处理
|
||
entity.scene?.eventSystem.emit('ai:attack', {
|
||
attacker: entity,
|
||
target,
|
||
damage: this.damage,
|
||
attackType: this.attackType
|
||
});
|
||
|
||
return TaskStatus.Success;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 示例2:移动动作
|
||
|
||
```typescript
|
||
@BehaviorNode({
|
||
displayName: '移动到位置',
|
||
category: '移动',
|
||
type: NodeType.Action,
|
||
icon: 'Navigation',
|
||
description: '移动到指定位置',
|
||
color: '#2196F3'
|
||
})
|
||
@ECSComponent('MoveToPositionAction')
|
||
@Serializable({ version: 1 })
|
||
export class MoveToPositionAction extends Component {
|
||
@BehaviorProperty({
|
||
label: '目标位置变量',
|
||
type: PropertyType.Blackboard,
|
||
description: '黑板中的目标位置变量'
|
||
})
|
||
@Serialize()
|
||
targetVar: string = 'targetPosition';
|
||
|
||
@BehaviorProperty({
|
||
label: '移动速度',
|
||
type: PropertyType.Number,
|
||
min: 0,
|
||
max: 100,
|
||
step: 0.1
|
||
})
|
||
@Serialize()
|
||
speed: number = 5.0;
|
||
|
||
@BehaviorProperty({
|
||
label: '到达距离',
|
||
type: PropertyType.Number,
|
||
min: 0.1,
|
||
max: 10
|
||
})
|
||
@Serialize()
|
||
arrivalDistance: number = 0.5;
|
||
|
||
execute(entity: Entity, blackboard?: BlackboardComponent, deltaTime?: number): TaskStatus {
|
||
const targetPos = blackboard?.getValue(this.targetVar);
|
||
const currentPos = blackboard?.getValue('position');
|
||
|
||
if (!targetPos || !currentPos) {
|
||
return TaskStatus.Failure;
|
||
}
|
||
|
||
// 计算距离
|
||
const dx = targetPos.x - currentPos.x;
|
||
const dy = targetPos.y - currentPos.y;
|
||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||
|
||
// 到达目标
|
||
if (distance <= this.arrivalDistance) {
|
||
return TaskStatus.Success;
|
||
}
|
||
|
||
// 移动
|
||
const moveDistance = this.speed * (deltaTime || 0);
|
||
const ratio = Math.min(moveDistance / distance, 1);
|
||
|
||
currentPos.x += dx * ratio;
|
||
currentPos.y += dy * ratio;
|
||
blackboard?.setValue('position', currentPos);
|
||
|
||
return TaskStatus.Running;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 示例3:播放动画
|
||
|
||
```typescript
|
||
@BehaviorNode({
|
||
displayName: '播放动画',
|
||
category: '表现',
|
||
type: NodeType.Action,
|
||
icon: 'Film',
|
||
description: '播放角色动画',
|
||
color: '#9C27B0'
|
||
})
|
||
@ECSComponent('PlayAnimationAction')
|
||
@Serializable({ version: 1 })
|
||
export class PlayAnimationAction extends Component {
|
||
@BehaviorProperty({
|
||
label: '动画名称',
|
||
type: PropertyType.String,
|
||
required: true
|
||
})
|
||
@Serialize()
|
||
animationName: string = '';
|
||
|
||
@BehaviorProperty({
|
||
label: '是否循环',
|
||
type: PropertyType.Boolean
|
||
})
|
||
@Serialize()
|
||
loop: boolean = false;
|
||
|
||
execute(entity: Entity, blackboard?: BlackboardComponent): TaskStatus {
|
||
if (!this.animationName) {
|
||
return TaskStatus.Failure;
|
||
}
|
||
|
||
// 触发事件让游戏逻辑播放动画
|
||
entity.scene?.eventSystem.emit('ai:playAnimation', {
|
||
entity,
|
||
animationName: this.animationName,
|
||
loop: this.loop
|
||
});
|
||
|
||
return TaskStatus.Success;
|
||
}
|
||
}
|
||
```
|
||
|
||
## 注册组件
|
||
|
||
创建好组件后,需要导入以注册到编辑器:
|
||
|
||
在 `src/game/ai/index.ts` 中:
|
||
|
||
```typescript
|
||
// 导入所有自定义组件以注册到编辑器
|
||
import './AttackAction';
|
||
import './MoveToPositionAction';
|
||
import './PlayAnimationAction';
|
||
|
||
export function registerCustomActions() {
|
||
// 组件会通过装饰器自动注册
|
||
}
|
||
```
|
||
|
||
在游戏初始化时调用:
|
||
|
||
```typescript
|
||
import { registerCustomActions } from './game/ai';
|
||
|
||
// 在 Core.create() 之前调用
|
||
registerCustomActions();
|
||
Core.create();
|
||
```
|
||
|
||
## 与游戏逻辑集成
|
||
|
||
### 方式1:通过事件系统(推荐)
|
||
|
||
在动作中触发事件:
|
||
|
||
```typescript
|
||
execute(entity: Entity, blackboard?: BlackboardComponent): TaskStatus {
|
||
entity.scene?.eventSystem.emit('ai:attack', {
|
||
attacker: entity,
|
||
target: blackboard?.getValue('target'),
|
||
damage: this.damage
|
||
});
|
||
return TaskStatus.Success;
|
||
}
|
||
```
|
||
|
||
在游戏代码中监听:
|
||
|
||
```typescript
|
||
Core.scene.eventSystem.on('ai:attack', (data) => {
|
||
const { attacker, target, damage } = data;
|
||
// 执行实际的战斗逻辑
|
||
target.takeDamage(damage);
|
||
});
|
||
```
|
||
|
||
### 方式2:通过黑板传递对象
|
||
|
||
将游戏对象放入黑板:
|
||
|
||
```typescript
|
||
const blackboard = aiEntity.getComponent(BlackboardComponent);
|
||
blackboard?.setValue('gameController', this.gameController);
|
||
blackboard?.setValue('player', this.player);
|
||
```
|
||
|
||
在动作中使用:
|
||
|
||
```typescript
|
||
execute(entity: Entity, blackboard?: BlackboardComponent): TaskStatus {
|
||
const gameController = blackboard?.getValue('gameController');
|
||
const player = blackboard?.getValue('player');
|
||
|
||
gameController?.attack(player, this.damage);
|
||
return TaskStatus.Success;
|
||
}
|
||
```
|
||
|
||
## 最佳实践
|
||
|
||
### 1. 保持动作简单
|
||
|
||
每个动作组件应该只做一件事:
|
||
|
||
```typescript
|
||
// 好的做法
|
||
class AttackAction { } // 只负责攻击
|
||
class MoveAction { } // 只负责移动
|
||
class PlayAnimationAction { } // 只负责播放动画
|
||
|
||
// 不好的做法
|
||
class AttackAndMoveAndPlayAnimation { } // 做太多事情
|
||
```
|
||
|
||
### 2. 使用事件解耦
|
||
|
||
动作不应该直接调用游戏逻辑,而是通过事件:
|
||
|
||
```typescript
|
||
// 好的做法
|
||
execute(...): TaskStatus {
|
||
entity.scene?.eventSystem.emit('ai:attack', data);
|
||
return TaskStatus.Success;
|
||
}
|
||
|
||
// 不好的做法
|
||
execute(...): TaskStatus {
|
||
// 直接调用游戏代码,导致耦合
|
||
GameManager.instance.battle.performAttack(...);
|
||
return TaskStatus.Success;
|
||
}
|
||
```
|
||
|
||
### 3. 参数使用黑板变量
|
||
|
||
需要动态的值应该从黑板读取:
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '目标变量',
|
||
type: 'blackboard' // 让策划选择黑板变量
|
||
})
|
||
targetVar: string = 'target';
|
||
|
||
execute(...): TaskStatus {
|
||
const target = blackboard?.getValue(this.targetVar);
|
||
// 使用target...
|
||
}
|
||
```
|
||
|
||
### 4. 提供合理的默认值
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '伤害值',
|
||
type: 'number',
|
||
min: 0,
|
||
max: 100
|
||
})
|
||
@Serialize()
|
||
damage: number = 10; // 合理的默认值
|
||
```
|
||
|
||
### 5. 添加详细的描述
|
||
|
||
```typescript
|
||
@BehaviorNode({
|
||
displayName: '攻击目标',
|
||
description: '对黑板中的目标造成伤害,如果目标不存在则失败' // 清晰的描述
|
||
})
|
||
@BehaviorProperty({
|
||
label: '伤害值',
|
||
description: '每次攻击造成的伤害值' // 参数说明
|
||
})
|
||
```
|
||
|
||
## 调试技巧
|
||
|
||
### 添加日志
|
||
|
||
```typescript
|
||
execute(...): TaskStatus {
|
||
console.log(`[AttackAction] 攻击目标,伤害=${this.damage}`);
|
||
// ...
|
||
}
|
||
```
|
||
|
||
### 使用黑板监控
|
||
|
||
```typescript
|
||
execute(...): TaskStatus {
|
||
console.log('黑板状态:', blackboard?.getAllVariables());
|
||
// ...
|
||
}
|
||
```
|
||
|
||
## 常见问题
|
||
|
||
### 编辑器中看不到自定义组件?
|
||
|
||
确保:
|
||
1. 组件文件已被导入
|
||
2. 使用了正确的装饰器(`@BehaviorNode`、`@ECSComponent`)
|
||
3. 类型设置为 `NodeType.Action`
|
||
|
||
### 参数修改后不生效?
|
||
|
||
检查:
|
||
1. 是否使用了 `@Serialize()` 装饰器
|
||
2. 重新加载资产文件
|
||
3. 清除缓存重启编辑器
|
||
|
||
### 如何支持复杂参数?
|
||
|
||
对于复杂对象,使用JSON字符串:
|
||
|
||
```typescript
|
||
@BehaviorProperty({
|
||
label: '配置(JSON)',
|
||
type: 'code'
|
||
})
|
||
@Serialize()
|
||
configJson: string = '{}';
|
||
|
||
execute(...): TaskStatus {
|
||
const config = JSON.parse(this.configJson);
|
||
// 使用config...
|
||
}
|
||
```
|
||
|
||
## 下一步
|
||
|
||
- 学习[编辑器工作流](./editor-workflow.md)
|
||
- 阅读[最佳实践](./best-practices.md)
|