Skip to content

自定义动作组件

本教程介绍如何为项目创建专用的动作组件,供策划在编辑器中使用。

为什么需要自定义组件?

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;
    }
}

自定义节点类型

除了使用内置的节点类型(ActionConditionCompositeDecorator),你也可以定义自己的节点类型:

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 对象支持以下字段:

字段类型说明
componentstring渲染器组件名称(需在编辑器中注册)
propsobject传递给渲染器的属性配置
classNamestringCSS类名
styleobject内联样式

编辑器会根据 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...
}

下一步

Released under the MIT License.