自定义动作组件
本教程介绍如何为项目创建专用的动作组件,供策划在编辑器中使用。
为什么需要自定义组件?
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());
// ...
}常见问题
编辑器中看不到自定义组件?
确保:
- 组件文件已被导入
- 使用了正确的装饰器(
@BehaviorNode、@ECSComponent) - 类型设置为
NodeType.Action
参数修改后不生效?
检查:
- 是否使用了
@Serialize()装饰器 - 重新加载资产文件
- 清除缓存重启编辑器
如何支持复杂参数?
对于复杂对象,使用JSON字符串:
typescript
@BehaviorProperty({
label: '配置(JSON)',
type: 'code'
})
@Serialize()
configJson: string = '{}';
execute(...): TaskStatus {
const config = JSON.parse(this.configJson);
// 使用config...
}