Files
esengine/docs/guide/behavior-tree/custom-actions.md

735 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自定义动作组件
本教程介绍如何为项目创建专用的动作组件,供策划在编辑器中使用。
## 为什么需要自定义组件?
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)