fix(behavior-tree): 修复插件节点执行问题并完善文档
This commit is contained in:
@@ -82,6 +82,21 @@ export default defineConfig({
|
|||||||
{ text: 'WorldManager', link: '/guide/world-manager' }
|
{ text: 'WorldManager', link: '/guide/world-manager' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: '行为树系统 (Behavior Tree)',
|
||||||
|
link: '/guide/behavior-tree/',
|
||||||
|
items: [
|
||||||
|
{ text: '快速开始', link: '/guide/behavior-tree/getting-started' },
|
||||||
|
{ text: '核心概念', link: '/guide/behavior-tree/core-concepts' },
|
||||||
|
{ text: '编辑器指南', link: '/guide/behavior-tree/editor-guide' },
|
||||||
|
{ text: '编辑器工作流', link: '/guide/behavior-tree/editor-workflow' },
|
||||||
|
{ text: '自定义动作组件', link: '/guide/behavior-tree/custom-actions' },
|
||||||
|
{ text: 'Cocos Creator集成', link: '/guide/behavior-tree/cocos-integration' },
|
||||||
|
{ text: 'Laya引擎集成', link: '/guide/behavior-tree/laya-integration' },
|
||||||
|
{ text: '高级用法', link: '/guide/behavior-tree/advanced-usage' },
|
||||||
|
{ text: '最佳实践', link: '/guide/behavior-tree/best-practices' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{ text: '序列化系统 (Serialization)', link: '/guide/serialization' },
|
{ text: '序列化系统 (Serialization)', link: '/guide/serialization' },
|
||||||
{ text: '事件系统 (Event)', link: '/guide/event-system' },
|
{ text: '事件系统 (Event)', link: '/guide/event-system' },
|
||||||
{ text: '时间和定时器 (Time)', link: '/guide/time-and-timers' },
|
{ text: '时间和定时器 (Time)', link: '/guide/time-and-timers' },
|
||||||
|
|||||||
596
docs/guide/behavior-tree/advanced-usage.md
Normal file
596
docs/guide/behavior-tree/advanced-usage.md
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
# 高级用法
|
||||||
|
|
||||||
|
本文介绍行为树系统的高级功能和使用技巧。
|
||||||
|
|
||||||
|
## 子树系统
|
||||||
|
|
||||||
|
子树允许你将行为树的一部分抽取为独立的资产,实现复用和模块化。
|
||||||
|
|
||||||
|
### 创建子树
|
||||||
|
|
||||||
|
子树本质上就是一个独立的行为树资产:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BehaviorTreeBuilder, BlackboardValueType, TaskStatus } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
// 创建一个巡逻子树
|
||||||
|
const patrolSubtree = BehaviorTreeBuilder.create(scene, 'PatrolBehavior')
|
||||||
|
.blackboard()
|
||||||
|
.defineVariable('patrolPoints', BlackboardValueType.Array, [])
|
||||||
|
.defineVariable('currentIndex', BlackboardValueType.Number, 0)
|
||||||
|
.endBlackboard()
|
||||||
|
.sequence('PatrolSequence')
|
||||||
|
.action('MoveToNextPoint', (entity, blackboard) => {
|
||||||
|
const points = blackboard?.getValue('patrolPoints') || [];
|
||||||
|
const index = blackboard?.getValue('currentIndex') || 0;
|
||||||
|
|
||||||
|
if (points.length === 0) return TaskStatus.Failure;
|
||||||
|
|
||||||
|
const target = points[index];
|
||||||
|
console.log(`移动到巡逻点 ${index}:`, target);
|
||||||
|
|
||||||
|
// 移动逻辑...
|
||||||
|
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.action('UpdateIndex', (entity, blackboard) => {
|
||||||
|
const points = blackboard?.getValue('patrolPoints') || [];
|
||||||
|
const index = blackboard?.getValue('currentIndex') || 0;
|
||||||
|
const nextIndex = (index + 1) % points.length;
|
||||||
|
blackboard?.setValue('currentIndex', nextIndex);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.wait(1.0)
|
||||||
|
.end()
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用SubTree节点
|
||||||
|
|
||||||
|
在主树中引用子树:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mainTree = BehaviorTreeBuilder.create(scene, 'EnemyAI')
|
||||||
|
.blackboard()
|
||||||
|
.defineVariable('health', BlackboardValueType.Number, 100)
|
||||||
|
.endBlackboard()
|
||||||
|
.selector('Root')
|
||||||
|
.sequence('Combat')
|
||||||
|
.compareBlackboardValue('health', CompareOperator.Greater, 50)
|
||||||
|
.action('Attack', () => TaskStatus.Success)
|
||||||
|
.end()
|
||||||
|
// 使用子树
|
||||||
|
.subTree(patrolSubtree, {
|
||||||
|
inheritParentBlackboard: true, // 继承父黑板
|
||||||
|
propagateFailure: true // 传播失败状态
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 从资产加载子树
|
||||||
|
|
||||||
|
使用编辑器创建的子树资产:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
BehaviorTreeAssetSerializer,
|
||||||
|
BehaviorTreeAssetLoader,
|
||||||
|
BehaviorTreeBuilder
|
||||||
|
} from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
// 加载子树资产
|
||||||
|
const subtreeJson = await loadFile('patrol-behavior.btree.json');
|
||||||
|
const subtreeAsset = BehaviorTreeAssetSerializer.deserialize(subtreeJson);
|
||||||
|
|
||||||
|
// 在主树中使用
|
||||||
|
const mainTree = BehaviorTreeBuilder.create(scene, 'MainAI')
|
||||||
|
.selector('Root')
|
||||||
|
.subTreeFromAsset(subtreeAsset, scene, {
|
||||||
|
namePrefix: 'Patrol',
|
||||||
|
inheritParentBlackboard: true
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 子树黑板继承
|
||||||
|
|
||||||
|
当启用 `inheritParentBlackboard` 时,子树可以访问父树的黑板变量:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 父树定义的变量
|
||||||
|
const parent = BehaviorTreeBuilder.create(scene, 'Parent')
|
||||||
|
.blackboard()
|
||||||
|
.defineVariable('playerPosition', BlackboardValueType.Vector3, { x: 0, y: 0, z: 0 })
|
||||||
|
.endBlackboard()
|
||||||
|
.subTree(childTree, { inheritParentBlackboard: true })
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 子树可以访问父树的 playerPosition 变量
|
||||||
|
const child = BehaviorTreeBuilder.create(scene, 'Child')
|
||||||
|
.action('UseParentData', (entity, blackboard) => {
|
||||||
|
const playerPos = blackboard?.getValue('playerPosition');
|
||||||
|
console.log('玩家位置:', playerPos);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 异步操作
|
||||||
|
|
||||||
|
### 异步动作
|
||||||
|
|
||||||
|
对于需要多帧完成的操作,返回 `TaskStatus.Running`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('LoadResource', (entity, blackboard, deltaTime) => {
|
||||||
|
// 检查是否已开始加载
|
||||||
|
let loadingState = blackboard?.getValue('loadingState');
|
||||||
|
|
||||||
|
if (!loadingState) {
|
||||||
|
// 第一次执行,开始加载
|
||||||
|
startAsyncLoad().then(result => {
|
||||||
|
blackboard?.setValue('loadingState', 'completed');
|
||||||
|
blackboard?.setValue('loadedData', result);
|
||||||
|
});
|
||||||
|
blackboard?.setValue('loadingState', 'loading');
|
||||||
|
return TaskStatus.Running; // 继续等待
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingState === 'loading') {
|
||||||
|
return TaskStatus.Running; // 仍在加载中
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingState === 'completed') {
|
||||||
|
// 加载完成
|
||||||
|
blackboard?.setValue('loadingState', null);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskStatus.Failure;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 超时控制
|
||||||
|
|
||||||
|
使用装饰器实现超时:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.timeout(5.0, 'LoadTimeout') // 5秒超时
|
||||||
|
.action('SlowOperation', () => {
|
||||||
|
// 长时间运行的操作
|
||||||
|
return TaskStatus.Running;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 全局黑板
|
||||||
|
|
||||||
|
全局黑板在所有行为树实例之间共享数据。
|
||||||
|
|
||||||
|
### 创建全局黑板
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { GlobalBlackboard } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
// 设置全局变量
|
||||||
|
GlobalBlackboard.setValue('gameState', 'playing');
|
||||||
|
GlobalBlackboard.setValue('playerCount', 4);
|
||||||
|
GlobalBlackboard.setValue('difficulty', 'hard');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 在行为树中访问全局黑板
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('CheckGameState', (entity, blackboard) => {
|
||||||
|
const gameState = GlobalBlackboard.getValue('gameState');
|
||||||
|
|
||||||
|
if (gameState === 'paused') {
|
||||||
|
return TaskStatus.Failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 全局黑板监听
|
||||||
|
|
||||||
|
监听全局变量变化:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const unsubscribe = GlobalBlackboard.subscribe('difficulty', (newValue, oldValue) => {
|
||||||
|
console.log(`难度从 ${oldValue} 变为 ${newValue}`);
|
||||||
|
// 调整AI行为
|
||||||
|
});
|
||||||
|
|
||||||
|
// 取消监听
|
||||||
|
unsubscribe();
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
### 1. 使用对象池
|
||||||
|
|
||||||
|
复用行为树实体以减少GC压力:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class BehaviorTreePool {
|
||||||
|
private pool: Map<string, Entity[]> = new Map();
|
||||||
|
private scene: Scene;
|
||||||
|
|
||||||
|
constructor(scene: Scene) {
|
||||||
|
this.scene = scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire(asset: BehaviorTreeAsset, poolKey: string): Entity {
|
||||||
|
let pool = this.pool.get(poolKey);
|
||||||
|
|
||||||
|
if (!pool) {
|
||||||
|
pool = [];
|
||||||
|
this.pool.set(poolKey, pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pool.length > 0) {
|
||||||
|
const entity = pool.pop()!;
|
||||||
|
BehaviorTreeStarter.restart(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BehaviorTreeAssetLoader.instantiate(asset, this.scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
release(entity: Entity, poolKey: string) {
|
||||||
|
BehaviorTreeStarter.stop(entity);
|
||||||
|
|
||||||
|
const pool = this.pool.get(poolKey) || [];
|
||||||
|
pool.push(entity);
|
||||||
|
this.pool.set(poolKey, pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
for (const [key, pool] of this.pool) {
|
||||||
|
for (const entity of pool) {
|
||||||
|
entity.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.pool.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用示例
|
||||||
|
const pool = new BehaviorTreePool(scene);
|
||||||
|
|
||||||
|
// 获取AI实例
|
||||||
|
const enemyAI = pool.acquire(enemyAsset, 'enemy');
|
||||||
|
|
||||||
|
// 释放回池
|
||||||
|
pool.release(enemyAI, 'enemy');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 降低更新频率
|
||||||
|
|
||||||
|
对于不需要每帧更新的AI,可以在行为树内部使用节流逻辑:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 方法1: 在行为树根节点使用Cooldown装饰器
|
||||||
|
const ai = BehaviorTreeBuilder.create(scene, 'ThrottledAI')
|
||||||
|
.cooldown(0.1) // 每0.1秒执行一次
|
||||||
|
.selector()
|
||||||
|
// AI逻辑
|
||||||
|
.end()
|
||||||
|
.end()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 方法2: 在Action中实现自定义节流
|
||||||
|
.action('ThrottledAction', (entity, blackboard, deltaTime) => {
|
||||||
|
const lastUpdate = blackboard?.getValue('lastUpdateTime') || 0;
|
||||||
|
const currentTime = Date.now();
|
||||||
|
const updateInterval = 100; // 100ms
|
||||||
|
|
||||||
|
if (currentTime - lastUpdate < updateInterval) {
|
||||||
|
return TaskStatus.Running;
|
||||||
|
}
|
||||||
|
|
||||||
|
blackboard?.setValue('lastUpdateTime', currentTime);
|
||||||
|
|
||||||
|
// 执行实际逻辑
|
||||||
|
performAILogic();
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 条件缓存
|
||||||
|
|
||||||
|
缓存昂贵的条件检查结果:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('CacheExpensiveCheck', (entity, blackboard) => {
|
||||||
|
const cacheKey = 'expensiveCheckResult';
|
||||||
|
const cacheTime = blackboard?.getValue('expensiveCheckTime') || 0;
|
||||||
|
const currentTime = Date.now();
|
||||||
|
|
||||||
|
// 如果缓存未过期(1秒内),直接使用缓存结果
|
||||||
|
if (currentTime - cacheTime < 1000) {
|
||||||
|
const cachedResult = blackboard?.getValue(cacheKey);
|
||||||
|
return cachedResult ? TaskStatus.Success : TaskStatus.Failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行昂贵的检查
|
||||||
|
const result = performExpensiveCheck();
|
||||||
|
|
||||||
|
// 缓存结果
|
||||||
|
blackboard?.setValue(cacheKey, result);
|
||||||
|
blackboard?.setValue('expensiveCheckTime', currentTime);
|
||||||
|
|
||||||
|
return result ? TaskStatus.Success : TaskStatus.Failure;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 分帧执行
|
||||||
|
|
||||||
|
将大量计算分散到多帧:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('ProcessLargeDataset', (entity, blackboard, deltaTime) => {
|
||||||
|
const data = blackboard?.getValue('dataset') || [];
|
||||||
|
let processedIndex = blackboard?.getValue('processedIndex') || 0;
|
||||||
|
|
||||||
|
const batchSize = 100; // 每帧处理100个
|
||||||
|
const endIndex = Math.min(processedIndex + batchSize, data.length);
|
||||||
|
|
||||||
|
for (let i = processedIndex; i < endIndex; i++) {
|
||||||
|
// 处理单个数据项
|
||||||
|
processItem(data[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
blackboard?.setValue('processedIndex', endIndex);
|
||||||
|
|
||||||
|
if (endIndex >= data.length) {
|
||||||
|
// 全部处理完成
|
||||||
|
blackboard?.setValue('processedIndex', 0);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskStatus.Running; // 继续下一帧
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 序列化和反序列化
|
||||||
|
|
||||||
|
### JSON格式
|
||||||
|
|
||||||
|
标准的可读格式:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BehaviorTreeAssetSerializer } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
// 序列化为JSON
|
||||||
|
const asset = createBehaviorTreeAsset();
|
||||||
|
const json = BehaviorTreeAssetSerializer.serialize(asset);
|
||||||
|
|
||||||
|
// 保存到文件
|
||||||
|
await saveFile('ai-behavior.btree.json', json);
|
||||||
|
|
||||||
|
// 从JSON加载
|
||||||
|
const loadedJson = await loadFile('ai-behavior.btree.json');
|
||||||
|
const loadedAsset = BehaviorTreeAssetSerializer.deserialize(loadedJson);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 二进制格式
|
||||||
|
|
||||||
|
体积更小的二进制格式(通常比JSON小60-70%):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 序列化为二进制
|
||||||
|
const binary = BehaviorTreeAssetSerializer.serializeToBinary(asset);
|
||||||
|
|
||||||
|
// 保存二进制文件
|
||||||
|
await saveFile('ai-behavior.btree.bin', binary);
|
||||||
|
|
||||||
|
// 从二进制加载
|
||||||
|
const loadedBinary = await loadFile('ai-behavior.btree.bin');
|
||||||
|
const loadedAsset = BehaviorTreeAssetSerializer.deserializeFromBinary(loadedBinary);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 格式转换
|
||||||
|
|
||||||
|
在JSON和二进制之间转换:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// JSON转二进制
|
||||||
|
const jsonData = await loadFile('tree.btree.json');
|
||||||
|
const asset = BehaviorTreeAssetSerializer.deserialize(jsonData);
|
||||||
|
const binary = BehaviorTreeAssetSerializer.serializeToBinary(asset);
|
||||||
|
await saveFile('tree.btree.bin', binary);
|
||||||
|
|
||||||
|
// 二进制转JSON
|
||||||
|
const binaryData = await loadFile('tree.btree.bin');
|
||||||
|
const asset2 = BehaviorTreeAssetSerializer.deserializeFromBinary(binaryData);
|
||||||
|
const json = BehaviorTreeAssetSerializer.serialize(asset2);
|
||||||
|
await saveFile('tree.btree.json', json);
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 调试技巧
|
||||||
|
|
||||||
|
### 1. 日志节点
|
||||||
|
|
||||||
|
在关键位置添加日志:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.log('开始战斗序列', 'info')
|
||||||
|
.sequence('Combat')
|
||||||
|
.log('检查生命值', 'debug')
|
||||||
|
.compareBlackboardValue('health', CompareOperator.Greater, 0)
|
||||||
|
.log('执行攻击', 'info')
|
||||||
|
.action('Attack', () => TaskStatus.Success)
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 黑板快照
|
||||||
|
|
||||||
|
定期输出黑板状态:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('DebugBlackboard', (entity, blackboard) => {
|
||||||
|
console.log('=== 黑板快照 ===');
|
||||||
|
const vars = blackboard?.getAllVariables();
|
||||||
|
for (const [key, value] of Object.entries(vars || {})) {
|
||||||
|
console.log(`${key}:`, value);
|
||||||
|
}
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 条件断言
|
||||||
|
|
||||||
|
验证重要条件:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('AssertPlayerExists', (entity, blackboard) => {
|
||||||
|
const player = blackboard?.getValue('player');
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
console.error('断言失败: 玩家不存在');
|
||||||
|
return TaskStatus.Failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 性能分析
|
||||||
|
|
||||||
|
测量节点执行时间:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('ProfiledAction', (entity, blackboard) => {
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
// 执行操作
|
||||||
|
doSomething();
|
||||||
|
|
||||||
|
const elapsed = performance.now() - startTime;
|
||||||
|
console.log(`操作耗时: ${elapsed.toFixed(2)}ms`);
|
||||||
|
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 可视化调试
|
||||||
|
|
||||||
|
在编辑器中运行行为树并观察节点状态:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BehaviorTreeDebugger } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
// 启用调试模式
|
||||||
|
BehaviorTreeDebugger.enable(aiEntity);
|
||||||
|
|
||||||
|
// 获取当前执行路径
|
||||||
|
const executionPath = BehaviorTreeDebugger.getExecutionPath(aiEntity);
|
||||||
|
console.log('执行路径:', executionPath);
|
||||||
|
|
||||||
|
// 获取节点状态
|
||||||
|
const nodeStatus = BehaviorTreeDebugger.getNodeStatus(aiEntity, nodeId);
|
||||||
|
console.log('节点状态:', nodeStatus);
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 常见模式
|
||||||
|
|
||||||
|
### 1. 状态机模式
|
||||||
|
|
||||||
|
使用行为树实现状态机:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const fsm = BehaviorTreeBuilder.create(scene, 'StateMachine')
|
||||||
|
.blackboard()
|
||||||
|
.defineVariable('currentState', BlackboardValueType.String, 'idle')
|
||||||
|
.endBlackboard()
|
||||||
|
.selector('StateSwitch')
|
||||||
|
// Idle状态
|
||||||
|
.sequence('IdleState')
|
||||||
|
.checkBlackboardValue('currentState', 'idle')
|
||||||
|
.action('IdleBehavior', (e, bb) => {
|
||||||
|
console.log('执行Idle行为');
|
||||||
|
// 状态转换条件
|
||||||
|
if (shouldTransitionToMove()) {
|
||||||
|
bb?.setValue('currentState', 'move');
|
||||||
|
}
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
// Move状态
|
||||||
|
.sequence('MoveState')
|
||||||
|
.checkBlackboardValue('currentState', 'move')
|
||||||
|
.action('MoveBehavior', (e, bb) => {
|
||||||
|
console.log('执行Move行为');
|
||||||
|
if (shouldTransitionToAttack()) {
|
||||||
|
bb?.setValue('currentState', 'attack');
|
||||||
|
}
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
// Attack状态
|
||||||
|
.sequence('AttackState')
|
||||||
|
.checkBlackboardValue('currentState', 'attack')
|
||||||
|
.action('AttackBehavior', (e, bb) => {
|
||||||
|
console.log('执行Attack行为');
|
||||||
|
if (shouldTransitionToIdle()) {
|
||||||
|
bb?.setValue('currentState', 'idle');
|
||||||
|
}
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
.end()
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 优先级队列模式
|
||||||
|
|
||||||
|
按优先级执行任务:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.selector('PriorityQueue')
|
||||||
|
// 最高优先级:生存
|
||||||
|
.sequence('Survive')
|
||||||
|
.compareBlackboardValue('health', CompareOperator.Less, 20)
|
||||||
|
.action('Heal', () => TaskStatus.Success)
|
||||||
|
.end()
|
||||||
|
// 中优先级:战斗
|
||||||
|
.sequence('Combat')
|
||||||
|
.checkBlackboardExists('nearbyEnemy', true)
|
||||||
|
.action('Fight', () => TaskStatus.Success)
|
||||||
|
.end()
|
||||||
|
// 低优先级:收集资源
|
||||||
|
.sequence('Gather')
|
||||||
|
.action('CollectResources', () => TaskStatus.Success)
|
||||||
|
.end()
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 并行任务模式
|
||||||
|
|
||||||
|
同时执行多个任务:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.parallel(ParallelPolicy.RequireAll) // 所有任务都要成功
|
||||||
|
.action('PlayAnimation', () => TaskStatus.Success)
|
||||||
|
.action('PlaySound', () => TaskStatus.Success)
|
||||||
|
.action('SpawnParticles', () => TaskStatus.Success)
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看[自定义节点](./custom-nodes.md)学习如何创建自定义行为节点
|
||||||
|
- 阅读[最佳实践](./best-practices.md)了解行为树设计技巧
|
||||||
|
- 查看[节点参考](./node-reference.md)了解所有内置节点
|
||||||
551
docs/guide/behavior-tree/best-practices.md
Normal file
551
docs/guide/behavior-tree/best-practices.md
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
# 最佳实践
|
||||||
|
|
||||||
|
本文介绍行为树设计和使用的最佳实践,帮助你构建高效、可维护的AI系统。
|
||||||
|
|
||||||
|
## 行为树设计原则
|
||||||
|
|
||||||
|
### 1. 保持树的层次清晰
|
||||||
|
|
||||||
|
将复杂行为分解成清晰的层次结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
Root Selector
|
||||||
|
├── Emergency (高优先级:紧急情况)
|
||||||
|
│ ├── FleeFromDanger
|
||||||
|
│ └── CallForHelp
|
||||||
|
├── Combat (中优先级:战斗)
|
||||||
|
│ ├── Attack
|
||||||
|
│ └── Defend
|
||||||
|
└── Idle (低优先级:空闲)
|
||||||
|
├── Patrol
|
||||||
|
└── Rest
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 2. 单一职责原则
|
||||||
|
|
||||||
|
每个节点应该只做一件事:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 好的设计
|
||||||
|
.sequence('AttackSequence')
|
||||||
|
.condition(hasTarget, 'CheckTarget')
|
||||||
|
.action(aim, 'Aim')
|
||||||
|
.action(fire, 'Fire')
|
||||||
|
.end()
|
||||||
|
|
||||||
|
// 不好的设计 - 一个动作做太多事
|
||||||
|
.action('AttackPlayer', () => {
|
||||||
|
checkTarget();
|
||||||
|
aim();
|
||||||
|
fire();
|
||||||
|
playAnimation();
|
||||||
|
playSound();
|
||||||
|
// 太多职责了!
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 使用描述性名称
|
||||||
|
|
||||||
|
节点名称应该清楚地表达其功能:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 好的命名
|
||||||
|
.condition(isHealthLow, 'CheckHealthLow')
|
||||||
|
.action(findNearestHealthPack, 'FindHealthPack')
|
||||||
|
.action(moveToHealthPack, 'MoveToHealthPack')
|
||||||
|
|
||||||
|
// 不好的命名
|
||||||
|
.condition(check1, 'C1')
|
||||||
|
.action(doSomething, 'Action1')
|
||||||
|
.action(move, 'A2')
|
||||||
|
```
|
||||||
|
|
||||||
|
## 黑板变量管理
|
||||||
|
|
||||||
|
### 1. 变量命名规范
|
||||||
|
|
||||||
|
使用清晰的命名约定:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.blackboard()
|
||||||
|
// 状态变量
|
||||||
|
.defineVariable('currentState', BlackboardValueType.String, 'idle')
|
||||||
|
.defineVariable('isMoving', BlackboardValueType.Boolean, false)
|
||||||
|
|
||||||
|
// 目标和引用
|
||||||
|
.defineVariable('targetEnemy', BlackboardValueType.Object, null)
|
||||||
|
.defineVariable('patrolPoints', BlackboardValueType.Array, [])
|
||||||
|
|
||||||
|
// 配置参数
|
||||||
|
.defineVariable('attackRange', BlackboardValueType.Number, 5.0)
|
||||||
|
.defineVariable('moveSpeed', BlackboardValueType.Number, 10.0)
|
||||||
|
|
||||||
|
// 临时数据
|
||||||
|
.defineVariable('lastAttackTime', BlackboardValueType.Number, 0)
|
||||||
|
.defineVariable('searchAttempts', BlackboardValueType.Number, 0)
|
||||||
|
.endBlackboard()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 避免过度使用黑板
|
||||||
|
|
||||||
|
只在需要跨节点共享的数据才放入黑板:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 不好的做法 - 局部变量放黑板
|
||||||
|
.action('Calculate', (e, bb) => {
|
||||||
|
bb?.setValue('temp1', 10); // 不需要
|
||||||
|
bb?.setValue('temp2', 20); // 不需要
|
||||||
|
bb?.setValue('result', 30); // 如果其他节点需要,这个可以
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 好的做法 - 使用局部变量
|
||||||
|
.action('Calculate', (e, bb) => {
|
||||||
|
const temp1 = 10;
|
||||||
|
const temp2 = 20;
|
||||||
|
const result = temp1 + temp2;
|
||||||
|
bb?.setValue('calculationResult', result); // 只保存需要共享的结果
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 使用类型安全的访问
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 定义类型接口
|
||||||
|
interface EnemyBlackboard {
|
||||||
|
health: number;
|
||||||
|
target: Entity | null;
|
||||||
|
state: 'idle' | 'patrol' | 'chase' | 'attack';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用时进行类型检查
|
||||||
|
.action('UseBlackboard', (e, bb) => {
|
||||||
|
const health = bb?.getValue<number>('health');
|
||||||
|
const target = bb?.getValue<Entity | null>('target');
|
||||||
|
const state = bb?.getValue<string>('state');
|
||||||
|
|
||||||
|
if (health !== undefined && health < 30) {
|
||||||
|
bb?.setValue('state', 'flee');
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 条件节点设计
|
||||||
|
|
||||||
|
### 1. 条件应该是无副作用的
|
||||||
|
|
||||||
|
条件检查不应该修改状态:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 好的做法 - 只读检查
|
||||||
|
.condition((e, bb) => {
|
||||||
|
const health = bb?.getValue('health') || 0;
|
||||||
|
return health < 30;
|
||||||
|
}, 'IsHealthLow')
|
||||||
|
|
||||||
|
// 不好的做法 - 条件中修改状态
|
||||||
|
.condition((e, bb) => {
|
||||||
|
const health = bb?.getValue('health') || 0;
|
||||||
|
if (health < 30) {
|
||||||
|
bb?.setValue('needsHealing', true); // 不应该在条件中修改
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 复杂条件拆分
|
||||||
|
|
||||||
|
将复杂条件拆分为多个简单条件:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 不好的做法
|
||||||
|
.condition((e, bb) => {
|
||||||
|
const health = bb?.getValue('health');
|
||||||
|
const ammo = bb?.getValue('ammo');
|
||||||
|
const enemy = bb?.getValue('enemy');
|
||||||
|
const distance = calculateDistance(e, enemy);
|
||||||
|
|
||||||
|
return health > 50 && ammo > 0 && enemy && distance < 10;
|
||||||
|
}, 'ComplexCheck')
|
||||||
|
|
||||||
|
// 好的做法
|
||||||
|
.sequence('CanAttack')
|
||||||
|
.condition((e, bb) => (bb?.getValue('health') || 0) > 50, 'HasEnoughHealth')
|
||||||
|
.condition((e, bb) => (bb?.getValue('ammo') || 0) > 0, 'HasAmmo')
|
||||||
|
.condition((e, bb) => bb?.getValue('enemy') != null, 'HasTarget')
|
||||||
|
.condition((e, bb) => {
|
||||||
|
const enemy = bb?.getValue('enemy');
|
||||||
|
return calculateDistance(e, enemy) < 10;
|
||||||
|
}, 'InRange')
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 动作节点设计
|
||||||
|
|
||||||
|
### 1. 使用状态机模式处理长时间动作
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('ChargeLaser', (e, bb, dt) => {
|
||||||
|
// 初始化状态
|
||||||
|
if (!bb?.hasVariable('chargeState')) {
|
||||||
|
bb?.setValue('chargeState', 'charging');
|
||||||
|
bb?.setValue('chargeTime', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = bb?.getValue('chargeState');
|
||||||
|
const chargeTime = bb?.getValue('chargeTime') || 0;
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case 'charging':
|
||||||
|
bb?.setValue('chargeTime', chargeTime + dt);
|
||||||
|
|
||||||
|
if (chargeTime >= 3.0) {
|
||||||
|
bb?.setValue('chargeState', 'ready');
|
||||||
|
}
|
||||||
|
return TaskStatus.Running;
|
||||||
|
|
||||||
|
case 'ready':
|
||||||
|
// 发射激光
|
||||||
|
fireLaser();
|
||||||
|
bb?.setValue('chargeState', null);
|
||||||
|
bb?.setValue('chargeTime', 0);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return TaskStatus.Failure;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 错误处理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('LoadResource', async (e, bb) => {
|
||||||
|
try {
|
||||||
|
const resourceId = bb?.getValue('resourceId');
|
||||||
|
if (!resourceId) {
|
||||||
|
console.error('资源ID未设置');
|
||||||
|
return TaskStatus.Failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = await loadResource(resourceId);
|
||||||
|
bb?.setValue('loadedResource', resource);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('资源加载失败:', error);
|
||||||
|
bb?.setValue('loadError', error.message);
|
||||||
|
return TaskStatus.Failure;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能优化技巧
|
||||||
|
|
||||||
|
### 1. 使用冷却装饰器
|
||||||
|
|
||||||
|
避免高频执行昂贵操作:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.cooldown(1.0) // 最多每秒执行一次
|
||||||
|
.action('ExpensiveSearch', () => {
|
||||||
|
// 昂贵的搜索操作
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 缓存计算结果
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('FindNearestEnemy', (e, bb) => {
|
||||||
|
// 检查缓存是否有效
|
||||||
|
const cacheTime = bb?.getValue('enemyCacheTime') || 0;
|
||||||
|
const currentTime = Date.now();
|
||||||
|
|
||||||
|
if (currentTime - cacheTime < 500) { // 缓存500ms
|
||||||
|
// 使用缓存结果
|
||||||
|
return bb?.getValue('nearestEnemy') ? TaskStatus.Success : TaskStatus.Failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行搜索
|
||||||
|
const nearest = findNearestEnemy();
|
||||||
|
bb?.setValue('nearestEnemy', nearest);
|
||||||
|
bb?.setValue('enemyCacheTime', currentTime);
|
||||||
|
|
||||||
|
return nearest ? TaskStatus.Success : TaskStatus.Failure;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 使用早期退出
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.selector('FindTarget')
|
||||||
|
// 先检查缓存的目标
|
||||||
|
.condition((e, bb) => bb?.hasVariable('cachedTarget'), 'HasCachedTarget')
|
||||||
|
|
||||||
|
// 没有缓存才进行搜索
|
||||||
|
.action('SearchNewTarget', (e, bb) => {
|
||||||
|
const target = performExpensiveSearch();
|
||||||
|
bb?.setValue('cachedTarget', target);
|
||||||
|
return target ? TaskStatus.Success : TaskStatus.Failure;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 可维护性
|
||||||
|
|
||||||
|
### 1. 使用子树模块化
|
||||||
|
|
||||||
|
将可复用的行为提取为子树:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 巡逻子树
|
||||||
|
const patrolBehavior = BehaviorTreeBuilder.create(scene, 'Patrol')
|
||||||
|
.sequence()
|
||||||
|
.action('MoveToNextWaypoint', () => TaskStatus.Success)
|
||||||
|
.wait(2.0)
|
||||||
|
.end()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 主树中引用
|
||||||
|
const mainTree = BehaviorTreeBuilder.create(scene, 'EnemyAI')
|
||||||
|
.selector()
|
||||||
|
.sequence('Combat')
|
||||||
|
// 战斗逻辑
|
||||||
|
.end()
|
||||||
|
.subTree(patrolBehavior) // 复用巡逻行为
|
||||||
|
.end()
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 使用编辑器创建复杂树
|
||||||
|
|
||||||
|
对于复杂的AI,使用可视化编辑器:
|
||||||
|
|
||||||
|
- 更直观的结构
|
||||||
|
- 方便非程序员调整
|
||||||
|
- 易于版本控制
|
||||||
|
- 支持实时调试
|
||||||
|
|
||||||
|
|
||||||
|
### 3. 添加注释和文档
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ai = BehaviorTreeBuilder.create(scene, 'BossAI')
|
||||||
|
.blackboard()
|
||||||
|
.defineVariable('phase', BlackboardValueType.Number, 1) // 1=正常, 2=狂暴, 3=濒死
|
||||||
|
.endBlackboard()
|
||||||
|
|
||||||
|
.selector('MainBehavior')
|
||||||
|
// 阶段3:生命值<20%,使用终极技能
|
||||||
|
.sequence('Phase3')
|
||||||
|
.compareBlackboardValue('phase', CompareOperator.Equal, 3)
|
||||||
|
.action('UltimateAbility', () => TaskStatus.Success)
|
||||||
|
.end()
|
||||||
|
|
||||||
|
// 阶段2:生命值<50%,进入狂暴
|
||||||
|
.sequence('Phase2')
|
||||||
|
.compareBlackboardValue('phase', CompareOperator.Equal, 2)
|
||||||
|
.action('BerserkMode', () => TaskStatus.Success)
|
||||||
|
.end()
|
||||||
|
|
||||||
|
// 阶段1:正常战斗
|
||||||
|
.sequence('Phase1')
|
||||||
|
.action('NormalAttack', () => TaskStatus.Success)
|
||||||
|
.end()
|
||||||
|
.end()
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调试技巧
|
||||||
|
|
||||||
|
### 1. 使用日志节点
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.log('开始攻击序列', 'info')
|
||||||
|
.sequence('Attack')
|
||||||
|
.log('检查目标', 'debug')
|
||||||
|
.condition(hasTarget)
|
||||||
|
.log('执行攻击', 'info')
|
||||||
|
.action(attack)
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 添加断言
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('ValidateState', (e, bb) => {
|
||||||
|
const health = bb?.getValue('health');
|
||||||
|
const maxHealth = bb?.getValue('maxHealth');
|
||||||
|
|
||||||
|
console.assert(health !== undefined, 'health不应为undefined');
|
||||||
|
console.assert(maxHealth !== undefined, 'maxHealth不应为undefined');
|
||||||
|
console.assert(health <= maxHealth, `health(${health})不应大于maxHealth(${maxHealth})`);
|
||||||
|
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 状态可视化
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('DebugState', (e, bb) => {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.group('AI State');
|
||||||
|
console.log('Entity:', e.name);
|
||||||
|
console.log('Health:', bb?.getValue('health'));
|
||||||
|
console.log('State:', bb?.getValue('currentState'));
|
||||||
|
console.log('Target:', bb?.getValue('target'));
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见反模式
|
||||||
|
|
||||||
|
### 1. 过深的嵌套
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 不好 - 太深的嵌套
|
||||||
|
.selector()
|
||||||
|
.sequence()
|
||||||
|
.sequence()
|
||||||
|
.sequence()
|
||||||
|
.action('DeepAction', () => TaskStatus.Success)
|
||||||
|
.end()
|
||||||
|
.end()
|
||||||
|
.end()
|
||||||
|
.end()
|
||||||
|
|
||||||
|
// 好 - 使用子树扁平化
|
||||||
|
const innerBehavior = BehaviorTreeBuilder.create(scene, 'Inner')
|
||||||
|
.action('DeepAction', () => TaskStatus.Success)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
.selector()
|
||||||
|
.subTree(innerBehavior)
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 在行为树中实现游戏逻辑
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 不好 - 行为树不应包含具体游戏逻辑
|
||||||
|
.action('Attack', (e, bb) => {
|
||||||
|
const enemy = bb?.getValue('enemy');
|
||||||
|
const damage = calculateDamage(e.getComponent(Weapon));
|
||||||
|
enemy.health -= damage;
|
||||||
|
|
||||||
|
if (enemy.health <= 0) {
|
||||||
|
enemy.die();
|
||||||
|
e.experience += enemy.expReward;
|
||||||
|
}
|
||||||
|
|
||||||
|
playAttackAnimation();
|
||||||
|
playAttackSound();
|
||||||
|
// 太多细节了!
|
||||||
|
})
|
||||||
|
|
||||||
|
// 好 - 行为树只负责决策,具体逻辑由系统处理
|
||||||
|
.action('Attack', (e, bb) => {
|
||||||
|
const enemy = bb?.getValue('enemy');
|
||||||
|
// 发送攻击命令,具体逻辑由战斗系统处理
|
||||||
|
Core.ecsAPI?.emit('combat:attack', { attacker: e, target: enemy });
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 频繁修改黑板
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 不好 - 每帧都修改黑板
|
||||||
|
.action('UpdatePosition', (e, bb, dt) => {
|
||||||
|
const pos = getCurrentPosition();
|
||||||
|
bb?.setValue('position', pos); // 每帧都set
|
||||||
|
bb?.setValue('velocity', getVelocity());
|
||||||
|
bb?.setValue('rotation', getRotation());
|
||||||
|
return TaskStatus.Running;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 好 - 只在需要时修改
|
||||||
|
.action('UpdatePosition', (e, bb, dt) => {
|
||||||
|
const oldPos = bb?.getValue('position');
|
||||||
|
const newPos = getCurrentPosition();
|
||||||
|
|
||||||
|
// 只在位置变化时更新
|
||||||
|
if (!positionsEqual(oldPos, newPos)) {
|
||||||
|
bb?.setValue('position', newPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskStatus.Running;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试建议
|
||||||
|
|
||||||
|
### 1. 单元测试节点
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('AttackAction', () => {
|
||||||
|
it('应该在有目标时返回Success', () => {
|
||||||
|
const scene = new Scene();
|
||||||
|
const entity = scene.createEntity('Test');
|
||||||
|
const blackboard = entity.addComponent(new BlackboardComponent());
|
||||||
|
|
||||||
|
blackboard.setValue('target', mockEnemy);
|
||||||
|
blackboard.setValue('ammo', 10);
|
||||||
|
|
||||||
|
const result = attackAction(entity, blackboard, 0);
|
||||||
|
|
||||||
|
expect(result).toBe(TaskStatus.Success);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在没有弹药时返回Failure', () => {
|
||||||
|
const scene = new Scene();
|
||||||
|
const entity = scene.createEntity('Test');
|
||||||
|
const blackboard = entity.addComponent(new BlackboardComponent());
|
||||||
|
|
||||||
|
blackboard.setValue('target', mockEnemy);
|
||||||
|
blackboard.setValue('ammo', 0);
|
||||||
|
|
||||||
|
const result = attackAction(entity, blackboard, 0);
|
||||||
|
|
||||||
|
expect(result).toBe(TaskStatus.Failure);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 集成测试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('EnemyAI', () => {
|
||||||
|
it('应该在玩家接近时攻击', () => {
|
||||||
|
const scene = new Scene();
|
||||||
|
const ai = createEnemyAI(scene);
|
||||||
|
const blackboard = ai.getComponent(BlackboardComponent);
|
||||||
|
|
||||||
|
// 设置玩家在攻击范围内
|
||||||
|
blackboard?.setValue('player', mockPlayer);
|
||||||
|
blackboard?.setValue('distanceToPlayer', 5);
|
||||||
|
|
||||||
|
BehaviorTreeStarter.start(ai);
|
||||||
|
scene.update();
|
||||||
|
|
||||||
|
// 验证进入了攻击状态
|
||||||
|
expect(blackboard?.getValue('currentState')).toBe('attacking');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看[节点参考](./node-reference.md)了解所有内置节点
|
||||||
|
- 学习[自定义节点](./custom-nodes.md)扩展行为树功能
|
||||||
|
- 探索[高级用法](./advanced-usage.md)了解更多技巧
|
||||||
501
docs/guide/behavior-tree/cocos-integration.md
Normal file
501
docs/guide/behavior-tree/cocos-integration.md
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
# Cocos Creator 集成
|
||||||
|
|
||||||
|
本教程将引导你在 Cocos Creator 项目中集成和使用行为树系统。
|
||||||
|
|
||||||
|
## 前置要求
|
||||||
|
|
||||||
|
- Cocos Creator 3.x 或更高版本
|
||||||
|
- 基本的 TypeScript 知识
|
||||||
|
- 已完成[快速开始](./getting-started.md)教程
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
### 步骤1:安装依赖
|
||||||
|
|
||||||
|
在你的 Cocos Creator 项目根目录下:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/ecs-framework @esengine/behavior-tree
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤2:配置 tsconfig.json
|
||||||
|
|
||||||
|
确保 `tsconfig.json` 中包含以下配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"moduleResolution": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
建议的项目结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
assets/
|
||||||
|
├── scripts/
|
||||||
|
│ ├── ai/
|
||||||
|
│ │ ├── EnemyAIComponent.ts # AI 组件
|
||||||
|
│ │ └── PlayerDetector.ts # 检测器
|
||||||
|
│ ├── systems/
|
||||||
|
│ │ └── BehaviorTreeSystem.ts # 行为树系统
|
||||||
|
│ └── Main.ts # 主入口
|
||||||
|
├── resources/
|
||||||
|
│ └── behaviors/
|
||||||
|
│ ├── enemy-ai.btree.json # 行为树资产
|
||||||
|
│ └── patrol.btree.json # 子树资产
|
||||||
|
└── types/
|
||||||
|
└── enemy-ai.ts # 类型定义
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 初始化 ECS 和行为树
|
||||||
|
|
||||||
|
### 创建主入口组件
|
||||||
|
|
||||||
|
创建 `assets/scripts/Main.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { _decorator, Component } from 'cc';
|
||||||
|
import { Core, Scene } from '@esengine/ecs-framework';
|
||||||
|
import { BehaviorTreePlugin } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
const { ccclass } = _decorator;
|
||||||
|
|
||||||
|
@ccclass('Main')
|
||||||
|
export class Main extends Component {
|
||||||
|
async onLoad() {
|
||||||
|
// 初始化 ECS Core
|
||||||
|
Core.create();
|
||||||
|
|
||||||
|
// 安装行为树插件
|
||||||
|
const behaviorTreePlugin = new BehaviorTreePlugin();
|
||||||
|
await Core.installPlugin(behaviorTreePlugin);
|
||||||
|
|
||||||
|
// 创建并设置场景
|
||||||
|
const scene = new Scene();
|
||||||
|
behaviorTreePlugin.setupScene(scene);
|
||||||
|
Core.setScene(scene);
|
||||||
|
|
||||||
|
console.log('ECS 和行为树系统初始化完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime: number) {
|
||||||
|
// 更新 ECS(会自动更新场景)
|
||||||
|
Core.update(deltaTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy() {
|
||||||
|
// 清理资源
|
||||||
|
Core.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 添加组件到场景
|
||||||
|
|
||||||
|
1. 在场景中创建一个空节点(命名为 `GameManager`)
|
||||||
|
2. 添加 `Main` 组件到该节点
|
||||||
|
|
||||||
|
|
||||||
|
## 创建 AI 组件
|
||||||
|
|
||||||
|
创建 `assets/scripts/ai/EnemyAIComponent.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { _decorator, Component, Node } from 'cc';
|
||||||
|
import { Core, Entity } from '@esengine/ecs-framework';
|
||||||
|
import {
|
||||||
|
BehaviorTreeAssetSerializer,
|
||||||
|
BehaviorTreeAssetLoader,
|
||||||
|
BehaviorTreeStarter,
|
||||||
|
BlackboardComponent
|
||||||
|
} from '@esengine/behavior-tree';
|
||||||
|
import { resources } from 'cc';
|
||||||
|
|
||||||
|
const { ccclass, property } = _decorator;
|
||||||
|
|
||||||
|
@ccclass('EnemyAIComponent')
|
||||||
|
export class EnemyAIComponent extends Component {
|
||||||
|
@property
|
||||||
|
behaviorTreeAsset: string = 'behaviors/enemy-ai.btree';
|
||||||
|
|
||||||
|
private aiEntity: Entity | null = null;
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
// 加载行为树资产
|
||||||
|
await this.loadBehaviorTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBehaviorTree() {
|
||||||
|
try {
|
||||||
|
// 获取Core管理的场景
|
||||||
|
const scene = Core.scene;
|
||||||
|
if (!scene) {
|
||||||
|
console.error('场景未初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 resources 加载JSON资产
|
||||||
|
resources.load(this.behaviorTreeAsset, (err, jsonAsset: any) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('加载行为树失败:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取JSON字符串
|
||||||
|
const jsonString = jsonAsset.json ? JSON.stringify(jsonAsset.json) : jsonAsset.text;
|
||||||
|
|
||||||
|
// 反序列化
|
||||||
|
const btAsset = BehaviorTreeAssetSerializer.deserialize(jsonString);
|
||||||
|
|
||||||
|
// 实例化
|
||||||
|
this.aiEntity = BehaviorTreeAssetLoader.instantiate(
|
||||||
|
btAsset,
|
||||||
|
scene,
|
||||||
|
{
|
||||||
|
namePrefix: this.node.name
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设置黑板初始值
|
||||||
|
const blackboard = this.aiEntity.getComponent(BlackboardComponent);
|
||||||
|
if (blackboard) {
|
||||||
|
// 可以在这里设置引用到 Cocos 节点
|
||||||
|
blackboard.setValue('cocosNode', this.node);
|
||||||
|
blackboard.setValue('position', this.node.position.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动 AI
|
||||||
|
BehaviorTreeStarter.start(this.aiEntity);
|
||||||
|
|
||||||
|
console.log('敌人 AI 已启动');
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('初始化行为树失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy() {
|
||||||
|
// 停止 AI
|
||||||
|
if (this.aiEntity) {
|
||||||
|
BehaviorTreeStarter.stop(this.aiEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 与 Cocos 节点交互
|
||||||
|
|
||||||
|
### 在编辑器ExecuteAction节点中编写代码
|
||||||
|
|
||||||
|
在行为树编辑器中,可以使用 `Execute Action` 节点,并编写代码:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 获取 Cocos 节点
|
||||||
|
const cocosNode = blackboard.getValue('cocosNode');
|
||||||
|
|
||||||
|
// 播放攻击动画
|
||||||
|
const animation = cocosNode.getComponent('Animation');
|
||||||
|
animation.play('attack');
|
||||||
|
|
||||||
|
return TaskStatus.Success;
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 完整示例:敌人 AI
|
||||||
|
|
||||||
|
### 行为树设计
|
||||||
|
|
||||||
|
使用编辑器创建 `enemy-ai.btree.json`:
|
||||||
|
|
||||||
|
```
|
||||||
|
RootSelector
|
||||||
|
├── CombatSequence
|
||||||
|
│ ├── CheckPlayerInRange (Condition)
|
||||||
|
│ ├── CheckHealthGood (Condition)
|
||||||
|
│ └── AttackPlayer (Action)
|
||||||
|
├── FleeSequence
|
||||||
|
│ ├── CheckHealthLow (Condition)
|
||||||
|
│ └── RunAway (Action)
|
||||||
|
└── PatrolSequence
|
||||||
|
├── PickWaypoint (Action)
|
||||||
|
├── MoveToWaypoint (Action)
|
||||||
|
└── Wait (Action)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 黑板变量
|
||||||
|
|
||||||
|
定义以下黑板变量:
|
||||||
|
|
||||||
|
- `cocosNode`:Node - Cocos 节点引用
|
||||||
|
- `health`:Number - 生命值
|
||||||
|
- `playerNode`:Object - 玩家节点引用
|
||||||
|
- `detectionRange`:Number - 检测范围
|
||||||
|
- `attackRange`:Number - 攻击范围
|
||||||
|
- `currentWaypoint`:Number - 当前路点索引
|
||||||
|
|
||||||
|
|
||||||
|
### 实现检测系统
|
||||||
|
|
||||||
|
创建 `assets/scripts/ai/PlayerDetector.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { _decorator, Component, Node, Vec3 } from 'cc';
|
||||||
|
import { BlackboardComponent } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
const { ccclass, property } = _decorator;
|
||||||
|
|
||||||
|
@ccclass('PlayerDetector')
|
||||||
|
export class PlayerDetector extends Component {
|
||||||
|
@property(Node)
|
||||||
|
player: Node = null;
|
||||||
|
|
||||||
|
@property
|
||||||
|
detectionRange: number = 10;
|
||||||
|
|
||||||
|
private blackboard: BlackboardComponent | null = null;
|
||||||
|
|
||||||
|
start() {
|
||||||
|
// 假设AI组件在同一节点上
|
||||||
|
const aiComponent = this.node.getComponent('EnemyAIComponent') as any;
|
||||||
|
if (aiComponent && aiComponent.aiEntity) {
|
||||||
|
this.blackboard = aiComponent.aiEntity.getComponent(BlackboardComponent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(deltaTime: number) {
|
||||||
|
if (!this.blackboard || !this.player) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算距离
|
||||||
|
const distance = Vec3.distance(this.node.position, this.player.position);
|
||||||
|
|
||||||
|
// 更新黑板
|
||||||
|
this.blackboard.setValue('playerNode', this.player);
|
||||||
|
this.blackboard.setValue('playerInRange', distance <= this.detectionRange);
|
||||||
|
this.blackboard.setValue('distanceToPlayer', distance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 资源管理
|
||||||
|
|
||||||
|
### 预加载行为树资产
|
||||||
|
|
||||||
|
在游戏启动时预加载所有行为树资产:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { resources } from 'cc';
|
||||||
|
|
||||||
|
async function preloadBehaviorTrees() {
|
||||||
|
const assets = [
|
||||||
|
'behaviors/enemy-ai',
|
||||||
|
'behaviors/boss-ai',
|
||||||
|
'behaviors/patrol'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of assets) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
resources.preload(path, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('行为树资产预加载完成');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 AssetManager
|
||||||
|
|
||||||
|
对于动态加载,可以使用 Cocos 的 AssetManager:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { assetManager } from 'cc';
|
||||||
|
|
||||||
|
assetManager.loadBundle('behaviors', (err, bundle) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('加载 bundle 失败:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle.load('enemy-ai', (err, asset) => {
|
||||||
|
if (!err) {
|
||||||
|
// 使用资产
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调试
|
||||||
|
|
||||||
|
### 可视化调试信息
|
||||||
|
|
||||||
|
创建调试组件显示 AI 状态:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { _decorator, Component, Label } from 'cc';
|
||||||
|
import { BlackboardComponent } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
const { ccclass, property } = _decorator;
|
||||||
|
|
||||||
|
@ccclass('AIDebugger')
|
||||||
|
export class AIDebugger extends Component {
|
||||||
|
@property(Label)
|
||||||
|
debugLabel: Label = null;
|
||||||
|
|
||||||
|
private blackboard: BlackboardComponent | null = null;
|
||||||
|
|
||||||
|
start() {
|
||||||
|
const aiComponent = this.node.getComponent('EnemyAIComponent') as any;
|
||||||
|
if (aiComponent && aiComponent.aiEntity) {
|
||||||
|
this.blackboard = aiComponent.aiEntity.getComponent(BlackboardComponent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (!this.blackboard || !this.debugLabel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = this.blackboard.getValue('health');
|
||||||
|
const state = this.blackboard.getValue('currentState');
|
||||||
|
|
||||||
|
this.debugLabel.string = `Health: ${health}\nState: ${state}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
### 1. 使用对象池
|
||||||
|
|
||||||
|
为 AI 实体使用对象池:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class AIEntityPool {
|
||||||
|
private pool: Entity[] = [];
|
||||||
|
private scene: Scene;
|
||||||
|
|
||||||
|
constructor(scene: Scene) {
|
||||||
|
this.scene = scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire(behaviorTreeAsset: any): Entity {
|
||||||
|
if (this.pool.length > 0) {
|
||||||
|
const entity = this.pool.pop()!;
|
||||||
|
BehaviorTreeStarter.restart(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BehaviorTreeAssetLoader.instantiate(behaviorTreeAsset, this.scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
release(entity: Entity) {
|
||||||
|
BehaviorTreeStarter.stop(entity);
|
||||||
|
this.pool.push(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 限制更新频率
|
||||||
|
|
||||||
|
对于远离相机的敌人,可以在行为树内部使用节流机制:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在行为树的Action节点中实现节流
|
||||||
|
function throttledAction(entity, blackboard, deltaTime) {
|
||||||
|
let lastUpdate = blackboard?.getValue('lastUpdateTime') || 0;
|
||||||
|
const currentTime = Date.now();
|
||||||
|
|
||||||
|
// 根据距离决定更新间隔
|
||||||
|
const distance = getDistanceToCamera();
|
||||||
|
const updateInterval = distance < 10 ? 0 : 200; // 远处敌人200ms更新一次
|
||||||
|
|
||||||
|
if (currentTime - lastUpdate < updateInterval) {
|
||||||
|
return TaskStatus.Running;
|
||||||
|
}
|
||||||
|
|
||||||
|
blackboard?.setValue('lastUpdateTime', currentTime);
|
||||||
|
|
||||||
|
// 执行实际逻辑
|
||||||
|
performAILogic();
|
||||||
|
return TaskStatus.Success;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 使用二进制格式
|
||||||
|
|
||||||
|
在构建时将 JSON 转换为二进制格式以减小包体:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在构建脚本中
|
||||||
|
node scripts/convert-bt-to-binary.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 多平台发布
|
||||||
|
|
||||||
|
### Web 平台
|
||||||
|
|
||||||
|
在 Web 平台,确保资源路径正确:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 使用相对路径
|
||||||
|
const assetPath = 'behaviors/enemy-ai';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 原生平台
|
||||||
|
|
||||||
|
原生平台可以使用二进制格式以获得更好的性能:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 检测平台
|
||||||
|
if (sys.isNative) {
|
||||||
|
// 加载二进制格式
|
||||||
|
assetPath = 'behaviors/enemy-ai.btree.bin';
|
||||||
|
} else {
|
||||||
|
// 加载 JSON 格式
|
||||||
|
assetPath = 'behaviors/enemy-ai.btree.json';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 行为树无法加载?
|
||||||
|
|
||||||
|
检查:
|
||||||
|
1. 资源路径是否正确(相对于 `resources` 目录)
|
||||||
|
2. 文件是否已添加到项目中
|
||||||
|
3. 检查控制台错误信息
|
||||||
|
|
||||||
|
### AI 不执行?
|
||||||
|
|
||||||
|
确保:
|
||||||
|
1. `Main` 组件的 `update` 方法被调用
|
||||||
|
2. `Scene.update()` 在每帧被调用
|
||||||
|
3. 行为树已通过 `BehaviorTreeStarter.start()` 启动
|
||||||
|
|
||||||
|
### 黑板变量不更新?
|
||||||
|
|
||||||
|
检查:
|
||||||
|
1. 变量名拼写是否正确
|
||||||
|
2. 是否在正确的时机更新变量
|
||||||
|
3. 使用 `BlackboardComponent.getValue()` 和 `setValue()` 方法
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看[高级用法](./advanced-usage.md)了解子树和异步加载
|
||||||
|
- 学习[最佳实践](./best-practices.md)优化你的 AI
|
||||||
484
docs/guide/behavior-tree/core-concepts.md
Normal file
484
docs/guide/behavior-tree/core-concepts.md
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
# 核心概念
|
||||||
|
|
||||||
|
本文介绍行为树系统的核心概念和工作原理。
|
||||||
|
|
||||||
|
## 什么是行为树?
|
||||||
|
|
||||||
|
行为树(Behavior Tree)是一种用于控制AI和自动化系统的决策结构。它通过树状层次结构组织任务,从根节点开始逐层执行,直到找到合适的行为。
|
||||||
|
|
||||||
|
### 与状态机的对比
|
||||||
|
|
||||||
|
传统状态机:
|
||||||
|
- 基于状态和转换
|
||||||
|
- 状态之间的转换复杂
|
||||||
|
- 难以扩展和维护
|
||||||
|
- 不便于复用
|
||||||
|
|
||||||
|
行为树:
|
||||||
|
- 基于任务和层次结构
|
||||||
|
- 模块化、易于复用
|
||||||
|
- 可视化编辑
|
||||||
|
- 灵活的决策逻辑
|
||||||
|
|
||||||
|
|
||||||
|
## 树结构
|
||||||
|
|
||||||
|
行为树由节点组成,形成树状结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
Root (根节点)
|
||||||
|
├── Selector (选择器)
|
||||||
|
│ ├── Sequence (序列)
|
||||||
|
│ │ ├── Condition (条件)
|
||||||
|
│ │ └── Action (动作)
|
||||||
|
│ └── Action (动作)
|
||||||
|
└── Sequence (序列)
|
||||||
|
├── Action (动作)
|
||||||
|
└── Wait (等待)
|
||||||
|
```
|
||||||
|
|
||||||
|
每个节点都有:
|
||||||
|
- 父节点(除了根节点)
|
||||||
|
- 零个或多个子节点
|
||||||
|
- 执行状态
|
||||||
|
- 返回结果
|
||||||
|
|
||||||
|
|
||||||
|
## 节点类型
|
||||||
|
|
||||||
|
### 复合节点(Composite)
|
||||||
|
|
||||||
|
复合节点有多个子节点,按特定规则执行它们。
|
||||||
|
|
||||||
|
#### Selector(选择器)
|
||||||
|
|
||||||
|
按顺序尝试执行子节点,直到某个子节点成功。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.selector('FindFood')
|
||||||
|
.action('EatNearbyFood', () => {
|
||||||
|
if (hasNearbyFood()) {
|
||||||
|
eat();
|
||||||
|
return TaskStatus.Success;
|
||||||
|
}
|
||||||
|
return TaskStatus.Failure;
|
||||||
|
})
|
||||||
|
.action('SearchFood', () => {
|
||||||
|
searchForFood();
|
||||||
|
return TaskStatus.Running;
|
||||||
|
})
|
||||||
|
.action('GiveUp', () => {
|
||||||
|
return TaskStatus.Failure;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
执行逻辑:
|
||||||
|
1. 尝试第一个子节点
|
||||||
|
2. 如果返回Success,选择器成功
|
||||||
|
3. 如果返回Failure,尝试下一个子节点
|
||||||
|
4. 如果返回Running,选择器返回Running
|
||||||
|
5. 所有子节点都失败时,选择器失败
|
||||||
|
|
||||||
|
|
||||||
|
#### Sequence(序列)
|
||||||
|
|
||||||
|
按顺序执行所有子节点,直到某个子节点失败。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.sequence('AttackSequence')
|
||||||
|
.condition((e, bb) => hasTarget()) // 检查是否有目标
|
||||||
|
.action('Aim', () => TaskStatus.Success) // 瞄准
|
||||||
|
.action('Fire', () => TaskStatus.Success) // 开火
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
执行逻辑:
|
||||||
|
1. 依次执行子节点
|
||||||
|
2. 如果子节点返回Failure,序列失败
|
||||||
|
3. 如果子节点返回Running,序列返回Running
|
||||||
|
4. 如果子节点返回Success,继续下一个子节点
|
||||||
|
5. 所有子节点都成功时,序列成功
|
||||||
|
|
||||||
|
|
||||||
|
#### Parallel(并行)
|
||||||
|
|
||||||
|
同时执行多个子节点。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ParallelPolicy } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
.parallel(ParallelPolicy.RequireAll) // 所有任务都要成功
|
||||||
|
.action('PlayAnimation', () => TaskStatus.Success)
|
||||||
|
.action('PlaySound', () => TaskStatus.Success)
|
||||||
|
.action('SpawnEffect', () => TaskStatus.Success)
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
策略类型:
|
||||||
|
- `RequireAll`:所有子节点都成功才成功
|
||||||
|
- `RequireOne`:任意一个子节点成功就成功
|
||||||
|
|
||||||
|
|
||||||
|
### 装饰器节点(Decorator)
|
||||||
|
|
||||||
|
装饰器节点只有一个子节点,用于修改子节点的行为或结果。
|
||||||
|
|
||||||
|
#### Inverter(反转)
|
||||||
|
|
||||||
|
反转子节点的结果:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.inverter()
|
||||||
|
.condition((e, bb) => isEnemyNearby()) // 检查敌人是否附近
|
||||||
|
.end()
|
||||||
|
// 如果有敌人返回false,没有敌人返回true
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Repeater(重复)
|
||||||
|
|
||||||
|
重复执行子节点:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.repeat(3) // 重复3次
|
||||||
|
.action('Jump', () => TaskStatus.Success)
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cooldown(冷却)
|
||||||
|
|
||||||
|
限制子节点的执行频率:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.cooldown(5.0) // 5秒冷却
|
||||||
|
.action('UseSpecialAbility', () => {
|
||||||
|
console.log('使用特殊技能');
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Timeout(超时)
|
||||||
|
|
||||||
|
限制子节点的执行时间:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.timeout(10.0) // 10秒超时
|
||||||
|
.action('ComplexTask', () => {
|
||||||
|
// 长时间运行的任务
|
||||||
|
return TaskStatus.Running;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 叶节点(Leaf)
|
||||||
|
|
||||||
|
叶节点没有子节点,执行具体的任务。
|
||||||
|
|
||||||
|
#### Action(动作)
|
||||||
|
|
||||||
|
执行具体操作:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('Move', (entity, blackboard, deltaTime) => {
|
||||||
|
const target = blackboard?.getValue('targetPosition');
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return TaskStatus.Failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动逻辑
|
||||||
|
const moved = moveTowards(target, deltaTime);
|
||||||
|
|
||||||
|
if (moved) {
|
||||||
|
return TaskStatus.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskStatus.Running;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Condition(条件)
|
||||||
|
|
||||||
|
检查条件:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.condition((entity, blackboard) => {
|
||||||
|
const health = blackboard?.getValue('health');
|
||||||
|
return health > 50;
|
||||||
|
}, 'CheckHealthHigh')
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Wait(等待)
|
||||||
|
|
||||||
|
等待指定时间:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.wait(2.0) // 等待2秒
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 任务状态
|
||||||
|
|
||||||
|
每个节点执行后返回以下状态之一:
|
||||||
|
|
||||||
|
### Success(成功)
|
||||||
|
|
||||||
|
任务成功完成。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('CollectCoin', () => {
|
||||||
|
coin.collect();
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Failure(失败)
|
||||||
|
|
||||||
|
任务执行失败。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.condition((e, bb) => {
|
||||||
|
const hasKey = bb?.getValue('hasKey');
|
||||||
|
return hasKey ? TaskStatus.Success : TaskStatus.Failure;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running(运行中)
|
||||||
|
|
||||||
|
任务需要多帧完成,仍在执行中。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('ChargeLaser', (entity, blackboard, deltaTime) => {
|
||||||
|
let chargeTime = blackboard?.getValue('chargeTime') || 0;
|
||||||
|
chargeTime += deltaTime;
|
||||||
|
blackboard?.setValue('chargeTime', chargeTime);
|
||||||
|
|
||||||
|
if (chargeTime >= 3.0) {
|
||||||
|
// 充能完成
|
||||||
|
blackboard?.setValue('chargeTime', 0);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskStatus.Running; // 继续充能
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invalid(无效)
|
||||||
|
|
||||||
|
节点未初始化或已重置。通常不需要手动返回此状态。
|
||||||
|
|
||||||
|
|
||||||
|
## 黑板系统
|
||||||
|
|
||||||
|
黑板(Blackboard)是行为树的数据存储系统,用于在节点之间共享数据。
|
||||||
|
|
||||||
|
### 本地黑板
|
||||||
|
|
||||||
|
每个行为树实例都有自己的本地黑板:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ai = BehaviorTreeBuilder.create(scene, 'EnemyAI')
|
||||||
|
.blackboard()
|
||||||
|
.defineVariable('health', BlackboardValueType.Number, 100)
|
||||||
|
.defineVariable('target', BlackboardValueType.Object, null)
|
||||||
|
.defineVariable('state', BlackboardValueType.String, 'idle')
|
||||||
|
.endBlackboard()
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 支持的数据类型
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BlackboardValueType } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
.blackboard()
|
||||||
|
.defineVariable('count', BlackboardValueType.Number, 0)
|
||||||
|
.defineVariable('name', BlackboardValueType.String, 'Enemy')
|
||||||
|
.defineVariable('isActive', BlackboardValueType.Boolean, true)
|
||||||
|
.defineVariable('position', BlackboardValueType.Vector2, { x: 0, y: 0 })
|
||||||
|
.defineVariable('direction', BlackboardValueType.Vector3, { x: 0, y: 0, z: 0 })
|
||||||
|
.defineVariable('data', BlackboardValueType.Object, {})
|
||||||
|
.defineVariable('items', BlackboardValueType.Array, [])
|
||||||
|
.endBlackboard()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 读写变量
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('UseBlackboard', (entity, blackboard) => {
|
||||||
|
// 读取变量
|
||||||
|
const health = blackboard?.getValue('health');
|
||||||
|
const target = blackboard?.getValue('target');
|
||||||
|
|
||||||
|
// 写入变量
|
||||||
|
blackboard?.setValue('health', health - 10);
|
||||||
|
blackboard?.setValue('lastAttackTime', Date.now());
|
||||||
|
|
||||||
|
// 检查变量是否存在
|
||||||
|
if (blackboard?.hasVariable('powerup')) {
|
||||||
|
const powerup = blackboard.getValue('powerup');
|
||||||
|
console.log('已获得强化:', powerup);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 全局黑板
|
||||||
|
|
||||||
|
所有行为树实例共享的黑板:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { GlobalBlackboard } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
// 设置全局变量
|
||||||
|
GlobalBlackboard.setValue('gameState', 'playing');
|
||||||
|
GlobalBlackboard.setValue('difficulty', 5);
|
||||||
|
|
||||||
|
// 在任何行为树中访问
|
||||||
|
.action('CheckGlobalState', () => {
|
||||||
|
const gameState = GlobalBlackboard.getValue('gameState');
|
||||||
|
|
||||||
|
if (gameState === 'paused') {
|
||||||
|
return TaskStatus.Failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 执行流程
|
||||||
|
|
||||||
|
### 初始化
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1. 初始化Core和场景
|
||||||
|
Core.create();
|
||||||
|
const scene = new Scene();
|
||||||
|
Core.setScene(scene);
|
||||||
|
|
||||||
|
// 2. 构建行为树
|
||||||
|
const ai = BehaviorTreeBuilder.create(scene, 'AI')
|
||||||
|
// ... 定义节点
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 3. 启动
|
||||||
|
BehaviorTreeStarter.start(ai);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新循环
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 每帧更新
|
||||||
|
gameLoop(() => {
|
||||||
|
const deltaTime = getDeltaTime();
|
||||||
|
Core.update(deltaTime); // Core会自动更新场景和所有行为树
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 执行顺序
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 从根节点开始
|
||||||
|
2. 根节点执行其逻辑(通常是Selector或Sequence)
|
||||||
|
3. 根节点的子节点按顺序执行
|
||||||
|
4. 每个子节点可能有自己的子节点
|
||||||
|
5. 叶节点执行具体操作并返回状态
|
||||||
|
6. 状态向上传播到父节点
|
||||||
|
7. 父节点根据策略决定如何处理子节点的状态
|
||||||
|
8. 最终根节点返回整体状态
|
||||||
|
```
|
||||||
|
|
||||||
|
### 执行示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const tree = BehaviorTreeBuilder.create(scene, 'Example')
|
||||||
|
.selector('Root') // 1. 执行选择器
|
||||||
|
.sequence('Branch1') // 2. 尝试第一个分支
|
||||||
|
.condition(() => false) // 3. 条件失败
|
||||||
|
.end() // 4. 序列失败,选择器继续下一个分支
|
||||||
|
.sequence('Branch2') // 5. 尝试第二个分支
|
||||||
|
.condition(() => true) // 6. 条件成功
|
||||||
|
.action(() => TaskStatus.Success) // 7. 动作成功
|
||||||
|
.end() // 8. 序列成功,选择器成功
|
||||||
|
.end() // 9. 整个树成功
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
执行流程图:
|
||||||
|
|
||||||
|
```
|
||||||
|
Root(Selector)
|
||||||
|
→ Branch1(Sequence)
|
||||||
|
→ Condition: Failure
|
||||||
|
→ Branch1 fails
|
||||||
|
→ Branch2(Sequence)
|
||||||
|
→ Condition: Success
|
||||||
|
→ Action: Success
|
||||||
|
→ Branch2 succeeds
|
||||||
|
→ Root succeeds
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## ECS集成
|
||||||
|
|
||||||
|
本框架的行为树完全基于ECS架构:
|
||||||
|
|
||||||
|
### 节点即实体
|
||||||
|
|
||||||
|
每个行为树节点都是一个Entity:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 行为树节点在内部被表示为:
|
||||||
|
const nodeEntity = scene.createEntity('SelectorNode');
|
||||||
|
nodeEntity.addComponent(new SelectorComponent());
|
||||||
|
nodeEntity.addComponent(new ParentComponent(parentEntity));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 组件存储数据
|
||||||
|
|
||||||
|
节点属性存储在组件中:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Action节点的数据组件
|
||||||
|
class ActionComponent extends Component {
|
||||||
|
actionFunc: ActionFunction;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blackboard组件
|
||||||
|
class BlackboardComponent extends Component {
|
||||||
|
private variables: Map<string, any>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 系统驱动行为
|
||||||
|
|
||||||
|
行为树系统负责更新所有节点:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class BehaviorTreeSystem extends EntitySystem {
|
||||||
|
update() {
|
||||||
|
// 更新所有活跃的行为树
|
||||||
|
for (const entity of this.entities) {
|
||||||
|
const root = entity.getComponent(BehaviorTreeRootComponent);
|
||||||
|
if (root && root.isActive) {
|
||||||
|
this.updateNode(root.rootEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
现在你已经理解了行为树的核心概念,接下来可以:
|
||||||
|
|
||||||
|
- 查看[快速开始](./getting-started.md)创建第一个行为树
|
||||||
|
- 学习[编辑器使用指南](./editor-guide.md)可视化创建行为树
|
||||||
|
- 探索[高级用法](./advanced-usage.md)了解更多功能
|
||||||
|
- 阅读[最佳实践](./best-practices.md)学习设计模式
|
||||||
734
docs/guide/behavior-tree/custom-actions.md
Normal file
734
docs/guide/behavior-tree/custom-actions.md
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
# 自定义动作组件
|
||||||
|
|
||||||
|
本教程介绍如何为项目创建专用的动作组件,供策划在编辑器中使用。
|
||||||
|
|
||||||
|
## 为什么需要自定义组件?
|
||||||
|
|
||||||
|
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)
|
||||||
110
docs/guide/behavior-tree/editor-guide.md
Normal file
110
docs/guide/behavior-tree/editor-guide.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# 行为树编辑器使用指南
|
||||||
|
|
||||||
|
行为树编辑器提供了可视化的方式来创建和编辑行为树。
|
||||||
|
|
||||||
|
## 启动编辑器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/editor-app
|
||||||
|
npm run tauri:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 基本操作
|
||||||
|
|
||||||
|
### 打开行为树编辑器
|
||||||
|
|
||||||
|
通过以下方式打开行为树编辑器窗口:
|
||||||
|
|
||||||
|
1. 在资产浏览器中双击 `.btree` 文件
|
||||||
|
2. 菜单栏:`窗口` → 选择行为树编辑器相关插件
|
||||||
|
|
||||||
|
### 创建新行为树
|
||||||
|
|
||||||
|
在行为树编辑器窗口的工具栏中点击"新建"按钮(加号图标)
|
||||||
|
|
||||||
|
### 保存行为树
|
||||||
|
|
||||||
|
在行为树编辑器窗口的工具栏中点击"保存"按钮(磁盘图标)
|
||||||
|
|
||||||
|
### 添加节点
|
||||||
|
|
||||||
|
从左侧节点面板拖拽节点到画布:
|
||||||
|
- 复合节点:Selector、Sequence、Parallel
|
||||||
|
- 装饰器:Inverter、Repeater、UntilFail等
|
||||||
|
- 动作节点:ExecuteAction、Wait等
|
||||||
|
- 条件节点:Condition
|
||||||
|
|
||||||
|
### 连接节点
|
||||||
|
|
||||||
|
拖拽父节点底部的连接点到子节点顶部建立连接
|
||||||
|
|
||||||
|
### 删除节点
|
||||||
|
|
||||||
|
选中节点后按 `Delete` 或 `Backspace` 键
|
||||||
|
|
||||||
|
### 编辑属性
|
||||||
|
|
||||||
|
点击节点后在右侧属性面板中编辑节点参数
|
||||||
|
|
||||||
|
## 黑板变量
|
||||||
|
|
||||||
|
在黑板面板中管理共享数据:
|
||||||
|
|
||||||
|
1. 点击"添加变量"按钮
|
||||||
|
2. 输入变量名、选择类型并设置默认值
|
||||||
|
3. 在节点中通过变量名引用黑板变量
|
||||||
|
|
||||||
|
支持的变量类型:
|
||||||
|
- Number:数字
|
||||||
|
- String:字符串
|
||||||
|
- Boolean:布尔值
|
||||||
|
- Object:对象引用
|
||||||
|
|
||||||
|
## 导出运行时资产
|
||||||
|
|
||||||
|
### 导出步骤
|
||||||
|
|
||||||
|
1. 点击工具栏的"导出"按钮
|
||||||
|
2. 选择导出模式:
|
||||||
|
- 当前文件:仅导出当前打开的行为树
|
||||||
|
- 工作区导出:导出项目中所有行为树
|
||||||
|
3. 选择资产输出路径
|
||||||
|
4. 选择TypeScript类型定义输出路径
|
||||||
|
5. 为每个文件选择导出格式:
|
||||||
|
- 二进制:.btree.bin(默认,文件更小,加载更快)
|
||||||
|
- JSON:.btree.json(可读性好,便于调试)
|
||||||
|
6. 点击"导出"按钮
|
||||||
|
|
||||||
|
### 加载运行时资产
|
||||||
|
|
||||||
|
`deserialize`方法会自动识别数据格式(JSON或二进制):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BehaviorTreeAssetSerializer, BehaviorTreeAssetLoader } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
// 加载二进制格式
|
||||||
|
const binaryData = await loadFile('enemy-ai.btree.bin'); // Uint8Array
|
||||||
|
const asset = BehaviorTreeAssetSerializer.deserialize(binaryData);
|
||||||
|
const aiEntity = BehaviorTreeAssetLoader.instantiate(asset, scene);
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BehaviorTreeAssetSerializer, BehaviorTreeAssetLoader } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
// 加载JSON格式
|
||||||
|
const jsonString = await loadFile('enemy-ai.btree.json'); // string
|
||||||
|
const asset = BehaviorTreeAssetSerializer.deserialize(jsonString);
|
||||||
|
const aiEntity = BehaviorTreeAssetLoader.instantiate(asset, scene);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 支持的操作
|
||||||
|
|
||||||
|
- `Delete` / `Backspace`:删除选中的节点或连线
|
||||||
|
- `Ctrl` + 点击:多选节点
|
||||||
|
- 框选:拖拽空白区域进行框选
|
||||||
|
- 拖拽画布:按住鼠标中键或空格键拖拽
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看[编辑器工作流](./editor-workflow.md)了解完整的开发流程
|
||||||
|
- 查看[自定义动作](./custom-actions.md)学习如何扩展节点
|
||||||
240
docs/guide/behavior-tree/editor-workflow.md
Normal file
240
docs/guide/behavior-tree/editor-workflow.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# 编辑器工作流
|
||||||
|
|
||||||
|
本教程介绍如何使用行为树编辑器创建AI,并在游戏中加载使用。
|
||||||
|
|
||||||
|
## 完整流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 启动编辑器
|
||||||
|
2. 创建行为树并定义黑板变量
|
||||||
|
3. 添加和配置节点
|
||||||
|
4. 导出JSON文件
|
||||||
|
5. 在游戏中加载并使用
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用编辑器创建
|
||||||
|
|
||||||
|
### 启动编辑器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/editor-app
|
||||||
|
npm run tauri:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 基本操作
|
||||||
|
|
||||||
|
1. **创建行为树**:`文件` → `新建项目` → 创建行为树文件
|
||||||
|
2. **定义黑板变量**:在黑板面板中添加共享变量
|
||||||
|
3. **添加节点**:从节点面板拖拽到画布
|
||||||
|
4. **连接节点**:拖拽连接点建立父子关系
|
||||||
|
5. **配置属性**:选中节点后在属性面板编辑
|
||||||
|
6. **导出**:`文件` → `导出` → `JSON格式`
|
||||||
|
|
||||||
|
### 示例:敌人AI的黑板变量
|
||||||
|
|
||||||
|
在编辑器黑板面板中定义:
|
||||||
|
|
||||||
|
```
|
||||||
|
health: Number = 100
|
||||||
|
target: Object = null
|
||||||
|
moveSpeed: Number = 5.0
|
||||||
|
attackRange: Number = 2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例:行为树结构
|
||||||
|
|
||||||
|
```
|
||||||
|
Root: Selector
|
||||||
|
├── Combat Sequence
|
||||||
|
│ ├── CheckHasTarget (Condition)
|
||||||
|
│ ├── CheckInAttackRange (Condition)
|
||||||
|
│ └── ExecuteAttack (Action)
|
||||||
|
├── Patrol Sequence
|
||||||
|
│ ├── MoveToNextPatrolPoint (Action)
|
||||||
|
│ └── Wait 2s
|
||||||
|
└── Idle (Action)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 在游戏中加载
|
||||||
|
|
||||||
|
### 加载JSON资产
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Core, Scene } from '@esengine/ecs-framework';
|
||||||
|
import {
|
||||||
|
BehaviorTreePlugin,
|
||||||
|
BehaviorTreeAssetSerializer,
|
||||||
|
BehaviorTreeAssetLoader,
|
||||||
|
BehaviorTreeStarter
|
||||||
|
} from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
Core.create();
|
||||||
|
const plugin = new BehaviorTreePlugin();
|
||||||
|
await Core.installPlugin(plugin);
|
||||||
|
|
||||||
|
const scene = new Scene();
|
||||||
|
plugin.setupScene(scene);
|
||||||
|
Core.setScene(scene);
|
||||||
|
|
||||||
|
// 加载行为树
|
||||||
|
const jsonString = await loadJsonFromFile('enemy-ai.btree.json');
|
||||||
|
const asset = BehaviorTreeAssetSerializer.deserialize(jsonString);
|
||||||
|
const aiEntity = BehaviorTreeAssetLoader.instantiate(asset, scene);
|
||||||
|
|
||||||
|
// 设置黑板初始值
|
||||||
|
const blackboard = aiEntity.getComponent(BlackboardComponent);
|
||||||
|
blackboard?.setValue('health', 100);
|
||||||
|
blackboard?.setValue('moveSpeed', 5.0);
|
||||||
|
|
||||||
|
// 启动AI
|
||||||
|
BehaviorTreeStarter.start(aiEntity);
|
||||||
|
|
||||||
|
// 游戏循环
|
||||||
|
setInterval(() => {
|
||||||
|
Core.update(0.016); // 60 FPS
|
||||||
|
}, 16);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现自定义动作
|
||||||
|
|
||||||
|
编辑器中的ExecuteAction节点需要在代码中提供实际逻辑。有两种方式:
|
||||||
|
|
||||||
|
### 方式1:通过事件系统(推荐)
|
||||||
|
|
||||||
|
在Action节点中触发事件,在游戏代码中监听:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在编辑器的ExecuteAction节点中
|
||||||
|
entity.scene?.eventSystem.emit('ai:attack', {
|
||||||
|
attacker: entity,
|
||||||
|
target: blackboard?.getValue('target')
|
||||||
|
});
|
||||||
|
return TaskStatus.Success;
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在游戏代码中监听
|
||||||
|
Core.scene.eventSystem.on('ai:attack', (data) => {
|
||||||
|
const { attacker, target } = data;
|
||||||
|
// 执行实际的攻击逻辑
|
||||||
|
performAttack(attacker, target);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式2:创建自定义组件
|
||||||
|
|
||||||
|
创建专用的Action组件(详见[自定义动作](./custom-actions.md)):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Component, ECSComponent, Entity } from '@esengine/ecs-framework';
|
||||||
|
import { BehaviorNode, BehaviorProperty, NodeType, TaskStatus } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
@BehaviorNode({
|
||||||
|
displayName: '攻击目标',
|
||||||
|
category: '战斗',
|
||||||
|
type: NodeType.Action,
|
||||||
|
description: '对目标造成伤害'
|
||||||
|
})
|
||||||
|
@ECSComponent('AttackAction')
|
||||||
|
export class AttackAction extends Component {
|
||||||
|
@BehaviorProperty({
|
||||||
|
label: '伤害值',
|
||||||
|
type: 'number'
|
||||||
|
})
|
||||||
|
damage: number = 10;
|
||||||
|
|
||||||
|
execute(entity: Entity, blackboard?: BlackboardComponent): TaskStatus {
|
||||||
|
const target = blackboard?.getValue('target');
|
||||||
|
if (!target) return TaskStatus.Failure;
|
||||||
|
|
||||||
|
// 执行攻击逻辑
|
||||||
|
performAttack(entity, target, this.damage);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调试技巧
|
||||||
|
|
||||||
|
### 1. 使用日志
|
||||||
|
|
||||||
|
在编辑器中添加Log节点输出调试信息:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.log('进入战斗分支', 'info')
|
||||||
|
.action('Attack', (entity, blackboard) => {
|
||||||
|
console.log('目标:', blackboard?.getValue('target'));
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 监控黑板
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const blackboard = aiEntity.getComponent(BlackboardComponent);
|
||||||
|
console.log('黑板状态:', blackboard?.getAllVariables());
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 检查节点状态
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const node = aiEntity.getComponent(BehaviorTreeNode);
|
||||||
|
console.log('节点状态:', node?.status);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Core, Scene, Entity } from '@esengine/ecs-framework';
|
||||||
|
import {
|
||||||
|
BehaviorTreePlugin,
|
||||||
|
BehaviorTreeBuilder,
|
||||||
|
BehaviorTreeStarter,
|
||||||
|
BlackboardValueType,
|
||||||
|
TaskStatus
|
||||||
|
} from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
Core.create();
|
||||||
|
const plugin = new BehaviorTreePlugin();
|
||||||
|
await Core.installPlugin(plugin);
|
||||||
|
|
||||||
|
const scene = new Scene();
|
||||||
|
plugin.setupScene(scene);
|
||||||
|
Core.setScene(scene);
|
||||||
|
|
||||||
|
// 直接用代码构建(不用编辑器)
|
||||||
|
const aiEntity = BehaviorTreeBuilder.create(scene, 'EnemyAI')
|
||||||
|
.blackboard()
|
||||||
|
.defineVariable('health', BlackboardValueType.Number, 100)
|
||||||
|
.defineVariable('hasTarget', BlackboardValueType.Boolean, false)
|
||||||
|
.endBlackboard()
|
||||||
|
.selector('Root')
|
||||||
|
.sequence('Combat')
|
||||||
|
.condition((e, bb) => bb?.getValue('hasTarget') === true, 'CheckTarget')
|
||||||
|
.action('Attack', (e, bb) => {
|
||||||
|
console.log('攻击!');
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
.action('Idle', () => {
|
||||||
|
console.log('空闲');
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
BehaviorTreeStarter.start(aiEntity);
|
||||||
|
|
||||||
|
// 游戏循环
|
||||||
|
setInterval(() => {
|
||||||
|
Core.update(0.016);
|
||||||
|
}, 16);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看[自定义动作](./custom-actions.md)学习如何创建专用的Action组件
|
||||||
|
- 查看[高级用法](./advanced-usage.md)了解子树、异步操作等高级特性
|
||||||
|
- 查看[最佳实践](./best-practices.md)优化你的AI设计
|
||||||
313
docs/guide/behavior-tree/getting-started.md
Normal file
313
docs/guide/behavior-tree/getting-started.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
# 快速开始
|
||||||
|
|
||||||
|
本教程将引导你在5分钟内创建第一个行为树。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/behavior-tree
|
||||||
|
```
|
||||||
|
|
||||||
|
## 第一个行为树
|
||||||
|
|
||||||
|
让我们创建一个简单的AI行为树,实现"巡逻-发现敌人-攻击"的逻辑。
|
||||||
|
|
||||||
|
### 步骤1:导入依赖
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Core, Scene } from '@esengine/ecs-framework';
|
||||||
|
import {
|
||||||
|
BehaviorTreeBuilder,
|
||||||
|
BehaviorTreeStarter,
|
||||||
|
BehaviorTreePlugin,
|
||||||
|
BlackboardValueType,
|
||||||
|
TaskStatus,
|
||||||
|
CompareOperator
|
||||||
|
} from '@esengine/behavior-tree';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤2:安装插件
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
Core.create();
|
||||||
|
const plugin = new BehaviorTreePlugin();
|
||||||
|
await Core.installPlugin(plugin);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤3:创建场景并设置行为树系统
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const scene = new Scene();
|
||||||
|
plugin.setupScene(scene);
|
||||||
|
Core.setScene(scene);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤4:构建行为树
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const guardAI = BehaviorTreeBuilder.create(scene, 'GuardAI')
|
||||||
|
// 定义黑板变量
|
||||||
|
.blackboard()
|
||||||
|
.defineVariable('health', BlackboardValueType.Number, 100)
|
||||||
|
.defineVariable('hasEnemy', BlackboardValueType.Boolean, false)
|
||||||
|
.defineVariable('patrolPoint', BlackboardValueType.Number, 0)
|
||||||
|
.endBlackboard()
|
||||||
|
|
||||||
|
// 根选择器
|
||||||
|
.selector('RootSelector')
|
||||||
|
// 分支1:如果发现敌人且生命值高,则攻击
|
||||||
|
.sequence('CombatBranch')
|
||||||
|
.checkBlackboardExists('hasEnemy', true, 'CheckEnemy')
|
||||||
|
.compareBlackboardValue('health', CompareOperator.Greater, 30, 'CheckHealth')
|
||||||
|
.action('Attack', (entity, blackboard) => {
|
||||||
|
console.log('守卫正在攻击敌人');
|
||||||
|
// 模拟攻击逻辑
|
||||||
|
const health = blackboard?.getValue<number>('health') || 100;
|
||||||
|
blackboard?.setValue('health', health - 10);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
|
||||||
|
// 分支2:如果生命值低,则逃跑
|
||||||
|
.sequence('FleeBranch')
|
||||||
|
.compareBlackboardValue('health', CompareOperator.LessOrEqual, 30)
|
||||||
|
.action('Flee', (entity) => {
|
||||||
|
console.log('守卫生命值过低,正在逃跑');
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
|
||||||
|
// 分支3:默认巡逻
|
||||||
|
.sequence('PatrolBranch')
|
||||||
|
.action('MoveToNextPoint', (entity, blackboard) => {
|
||||||
|
const point = blackboard?.getValue<number>('patrolPoint') || 0;
|
||||||
|
const nextPoint = (point + 1) % 4;
|
||||||
|
blackboard?.setValue('patrolPoint', nextPoint);
|
||||||
|
console.log(`守卫移动到巡逻点 ${nextPoint}`);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.wait(2.0, 'WaitAtPoint') // 在巡逻点等待2秒
|
||||||
|
.end()
|
||||||
|
.end()
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤5:启动行为树
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
BehaviorTreeStarter.start(guardAI);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤6:运行游戏循环
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 模拟游戏循环
|
||||||
|
setInterval(() => {
|
||||||
|
Core.update(0.1); // 传入deltaTime(秒)
|
||||||
|
}, 100); // 每100ms更新一次
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完整代码
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Core, Scene } from '@esengine/ecs-framework';
|
||||||
|
import {
|
||||||
|
BehaviorTreeBuilder,
|
||||||
|
BehaviorTreeStarter,
|
||||||
|
BehaviorTreePlugin,
|
||||||
|
BlackboardValueType,
|
||||||
|
TaskStatus,
|
||||||
|
CompareOperator
|
||||||
|
} from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// 创建核心和场景
|
||||||
|
Core.create();
|
||||||
|
const plugin = new BehaviorTreePlugin();
|
||||||
|
await Core.installPlugin(plugin);
|
||||||
|
|
||||||
|
const scene = new Scene();
|
||||||
|
plugin.setupScene(scene);
|
||||||
|
Core.setScene(scene);
|
||||||
|
|
||||||
|
// 构建行为树
|
||||||
|
const guardAI = BehaviorTreeBuilder.create(scene, 'GuardAI')
|
||||||
|
.blackboard()
|
||||||
|
.defineVariable('health', BlackboardValueType.Number, 100)
|
||||||
|
.defineVariable('hasEnemy', BlackboardValueType.Boolean, false)
|
||||||
|
.defineVariable('patrolPoint', BlackboardValueType.Number, 0)
|
||||||
|
.endBlackboard()
|
||||||
|
.selector('RootSelector')
|
||||||
|
.sequence('CombatBranch')
|
||||||
|
.checkBlackboardExists('hasEnemy', true)
|
||||||
|
.compareBlackboardValue('health', CompareOperator.Greater, 30)
|
||||||
|
.action('Attack', (entity, blackboard) => {
|
||||||
|
console.log('守卫正在攻击敌人');
|
||||||
|
const health = blackboard?.getValue<number>('health') || 100;
|
||||||
|
blackboard?.setValue('health', health - 10);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
.sequence('FleeBranch')
|
||||||
|
.compareBlackboardValue('health', CompareOperator.LessOrEqual, 30)
|
||||||
|
.action('Flee', () => {
|
||||||
|
console.log('守卫生命值过低,正在逃跑');
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
.sequence('PatrolBranch')
|
||||||
|
.action('MoveToNextPoint', (entity, blackboard) => {
|
||||||
|
const point = blackboard?.getValue<number>('patrolPoint') || 0;
|
||||||
|
const nextPoint = (point + 1) % 4;
|
||||||
|
blackboard?.setValue('patrolPoint', nextPoint);
|
||||||
|
console.log(`守卫移动到巡逻点 ${nextPoint}`);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.wait(2.0)
|
||||||
|
.end()
|
||||||
|
.end()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 启动AI
|
||||||
|
BehaviorTreeStarter.start(guardAI);
|
||||||
|
|
||||||
|
// 运行游戏循环
|
||||||
|
setInterval(() => {
|
||||||
|
Core.update(0.1); // 传入deltaTime(秒)
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// 5秒后模拟发现敌人
|
||||||
|
setTimeout(() => {
|
||||||
|
const blackboard = guardAI.getComponent(BlackboardComponent);
|
||||||
|
blackboard?.setValue('hasEnemy', true);
|
||||||
|
console.log('发现敌人!');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行结果
|
||||||
|
|
||||||
|
运行程序后,你会看到类似的输出:
|
||||||
|
|
||||||
|
```
|
||||||
|
守卫移动到巡逻点 1
|
||||||
|
守卫移动到巡逻点 2
|
||||||
|
守卫移动到巡逻点 3
|
||||||
|
发现敌人!
|
||||||
|
守卫正在攻击敌人
|
||||||
|
守卫正在攻击敌人
|
||||||
|
守卫正在攻击敌人
|
||||||
|
...
|
||||||
|
守卫生命值过低,正在逃跑
|
||||||
|
```
|
||||||
|
|
||||||
|
## 理解代码
|
||||||
|
|
||||||
|
### 黑板变量
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.blackboard()
|
||||||
|
.defineVariable('health', BlackboardValueType.Number, 100)
|
||||||
|
.defineVariable('hasEnemy', BlackboardValueType.Boolean, false)
|
||||||
|
.defineVariable('patrolPoint', BlackboardValueType.Number, 0)
|
||||||
|
.endBlackboard()
|
||||||
|
```
|
||||||
|
|
||||||
|
黑板用于在节点之间共享数据。这里定义了三个变量:
|
||||||
|
- `health`:守卫的生命值
|
||||||
|
- `hasEnemy`:是否发现敌人
|
||||||
|
- `patrolPoint`:当前巡逻点编号
|
||||||
|
|
||||||
|
### 选择器节点
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.selector('RootSelector')
|
||||||
|
// 分支1
|
||||||
|
// 分支2
|
||||||
|
// 分支3
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
选择器按顺序尝试执行子节点,直到某个子节点返回成功。类似于编程中的 `if-else if-else`。
|
||||||
|
|
||||||
|
### 序列节点
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.sequence('CombatBranch')
|
||||||
|
.checkBlackboardExists('hasEnemy', true)
|
||||||
|
.compareBlackboardValue('health', CompareOperator.Greater, 30)
|
||||||
|
.action('Attack', ...)
|
||||||
|
.end()
|
||||||
|
```
|
||||||
|
|
||||||
|
序列节点按顺序执行所有子节点,如果任何一个失败则整个序列失败。类似于编程中的 `&&` 运算符。
|
||||||
|
|
||||||
|
### 自定义动作
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.action('Attack', (entity, blackboard, deltaTime) => {
|
||||||
|
// 你的自定义逻辑
|
||||||
|
console.log('执行攻击');
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
动作节点执行具体的操作并返回状态:
|
||||||
|
- `TaskStatus.Success`:成功完成
|
||||||
|
- `TaskStatus.Failure`:执行失败
|
||||||
|
- `TaskStatus.Running`:正在执行(需要多帧完成)
|
||||||
|
|
||||||
|
## 常见任务状态
|
||||||
|
|
||||||
|
行为树的每个节点返回以下状态之一:
|
||||||
|
|
||||||
|
- **Success**:任务成功完成
|
||||||
|
- **Failure**:任务执行失败
|
||||||
|
- **Running**:任务正在执行,需要在后续帧继续
|
||||||
|
- **Invalid**:无效状态(未初始化或已重置)
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
现在你已经创建了第一个行为树,接下来可以:
|
||||||
|
|
||||||
|
1. 学习[核心概念](./core-concepts.md)深入理解行为树原理
|
||||||
|
2. 尝试[编辑器使用指南](./editor-guide.md)可视化创建行为树
|
||||||
|
3. 查看[高级用法](./advanced-usage.md)了解更多功能
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 为什么行为树不执行?
|
||||||
|
|
||||||
|
确保:
|
||||||
|
1. 已经安装了 `BehaviorTreePlugin`
|
||||||
|
2. 调用了 `plugin.setupScene(scene)`
|
||||||
|
3. 调用了 `BehaviorTreeStarter.start(aiRoot)`
|
||||||
|
4. 场景的 `update()` 方法在游戏循环中被调用
|
||||||
|
|
||||||
|
### 如何调试行为树?
|
||||||
|
|
||||||
|
使用日志动作和控制台输出:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
.log('到达这个节点', 'info')
|
||||||
|
.action('MyAction', (entity, blackboard) => {
|
||||||
|
console.log('blackboard:', blackboard?.getAllVariables());
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 如何停止行为树?
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
BehaviorTreeStarter.stop(aiRoot);
|
||||||
|
```
|
||||||
|
|
||||||
|
或暂停后恢复:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
BehaviorTreeStarter.pause(aiRoot);
|
||||||
|
// ... 一段时间后
|
||||||
|
BehaviorTreeStarter.resume(aiRoot);
|
||||||
|
```
|
||||||
145
docs/guide/behavior-tree/index.md
Normal file
145
docs/guide/behavior-tree/index.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# 行为树系统
|
||||||
|
|
||||||
|
行为树(Behavior Tree)是一种用于游戏AI和自动化控制的强大工具。本框架提供了完全ECS化的行为树系统,所有节点都是实体和组件,充分利用了ECS的性能优势。
|
||||||
|
|
||||||
|
## 什么是行为树?
|
||||||
|
|
||||||
|
行为树是一种层次化的任务执行结构,由多个节点组成,每个节点负责特定的任务。行为树特别适合于:
|
||||||
|
|
||||||
|
- 游戏AI(敌人、NPC行为)
|
||||||
|
- 状态机的替代方案
|
||||||
|
- 复杂的决策逻辑
|
||||||
|
- 可视化的行为设计
|
||||||
|
|
||||||
|
## 核心特性
|
||||||
|
|
||||||
|
### 完全ECS化
|
||||||
|
- 所有节点都是实体(Entity)
|
||||||
|
- 节点属性存储在组件(Component)中
|
||||||
|
- 利用ECS的缓存友好特性
|
||||||
|
- 支持大规模AI实例
|
||||||
|
|
||||||
|
### 可视化编辑器
|
||||||
|
- 图形化节点编辑
|
||||||
|
- 实时预览和调试
|
||||||
|
- 拖拽式节点创建
|
||||||
|
- 支持子树复用
|
||||||
|
|
||||||
|
### 灵活的黑板系统
|
||||||
|
- 本地黑板(单个行为树)
|
||||||
|
- 全局黑板(所有行为树共享)
|
||||||
|
- 类型安全的变量访问
|
||||||
|
- 支持多种数据类型
|
||||||
|
|
||||||
|
### 强大的序列化
|
||||||
|
- JSON格式(可读性好)
|
||||||
|
- 二进制格式(体积小60-70%)
|
||||||
|
- 跨平台兼容
|
||||||
|
- 支持格式转换
|
||||||
|
|
||||||
|
### 引擎集成
|
||||||
|
- Cocos Creator 支持
|
||||||
|
- Laya 引擎支持
|
||||||
|
- 纯TypeScript实现
|
||||||
|
- 易于扩展到其他引擎
|
||||||
|
|
||||||
|
## 文档导航
|
||||||
|
|
||||||
|
### 入门教程
|
||||||
|
|
||||||
|
- **[快速开始](./getting-started.md)** - 5分钟上手行为树
|
||||||
|
- **[核心概念](./core-concepts.md)** - 理解行为树的基本原理
|
||||||
|
|
||||||
|
### 编辑器使用
|
||||||
|
|
||||||
|
- **[编辑器使用指南](./editor-guide.md)** - 可视化创建行为树
|
||||||
|
- **[节点参考](./node-reference.md)** - 所有内置节点的详细说明
|
||||||
|
|
||||||
|
### 引擎集成
|
||||||
|
|
||||||
|
- **[Cocos Creator 集成](./cocos-integration.md)** - 在 Cocos Creator 中使用行为树
|
||||||
|
- **[Laya 引擎集成](./laya-integration.md)** - 在 Laya 中使用行为树
|
||||||
|
|
||||||
|
### 高级主题
|
||||||
|
|
||||||
|
- **[高级用法](./advanced-usage.md)** - 子树、异步加载、性能优化
|
||||||
|
- **[自定义节点](./custom-nodes.md)** - 创建自定义行为节点
|
||||||
|
- **[最佳实践](./best-practices.md)** - 行为树设计模式和技巧
|
||||||
|
|
||||||
|
### API参考
|
||||||
|
|
||||||
|
- **[API 文档](../../api/behavior-tree/README.md)** - 完整的API参考
|
||||||
|
|
||||||
|
## 快速示例
|
||||||
|
|
||||||
|
### 代码方式创建
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Scene } from '@esengine/ecs-framework';
|
||||||
|
import {
|
||||||
|
BehaviorTreeBuilder,
|
||||||
|
BehaviorTreeStarter,
|
||||||
|
BlackboardValueType,
|
||||||
|
TaskStatus
|
||||||
|
} from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
const scene = new Scene();
|
||||||
|
|
||||||
|
// 创建敌人AI
|
||||||
|
const enemyAI = BehaviorTreeBuilder.create(scene, 'EnemyAI')
|
||||||
|
.blackboard()
|
||||||
|
.defineVariable('health', BlackboardValueType.Number, 100)
|
||||||
|
.defineVariable('target', BlackboardValueType.Object, null)
|
||||||
|
.endBlackboard()
|
||||||
|
.selector('MainBehavior')
|
||||||
|
// 如果生命值高,则攻击
|
||||||
|
.sequence('AttackBranch')
|
||||||
|
.compareBlackboardValue('health', CompareOperator.Greater, 50)
|
||||||
|
.action('Attack', () => {
|
||||||
|
console.log('Attacking player');
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
// 否则逃跑
|
||||||
|
.action('Flee', () => {
|
||||||
|
console.log('Fleeing from battle');
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 启动AI
|
||||||
|
BehaviorTreeStarter.start(enemyAI);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 编辑器方式创建
|
||||||
|
|
||||||
|
1. 打开行为树编辑器
|
||||||
|
2. 创建新的行为树资产
|
||||||
|
3. 拖拽节点到画布
|
||||||
|
4. 配置节点属性
|
||||||
|
5. 保存并导出
|
||||||
|
6. 在代码中加载使用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 加载编辑器创建的行为树
|
||||||
|
const asset = await loadBehaviorTree('enemy-ai.btree.json');
|
||||||
|
const ai = BehaviorTreeAssetLoader.instantiate(asset, scene);
|
||||||
|
BehaviorTreeStarter.start(ai);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
建议按照以下顺序学习:
|
||||||
|
|
||||||
|
1. 阅读[快速开始](./getting-started.md)了解基础用法
|
||||||
|
2. 学习[核心概念](./core-concepts.md)理解行为树原理
|
||||||
|
3. 尝试[编辑器使用指南](./editor-guide.md)可视化创建行为树
|
||||||
|
4. 根据你的引擎查看集成教程([Cocos](./cocos-integration.md) 或 [Laya](./laya-integration.md))
|
||||||
|
5. 探索[高级用法](./advanced-usage.md)提升技能
|
||||||
|
|
||||||
|
## 获取帮助
|
||||||
|
|
||||||
|
- 查看 [示例项目](https://github.com/esengine/ecs-framework/tree/master/examples)
|
||||||
|
- 提交 [Issue](https://github.com/esengine/ecs-framework/issues)
|
||||||
|
- 加入社区讨论
|
||||||
302
docs/guide/behavior-tree/laya-integration.md
Normal file
302
docs/guide/behavior-tree/laya-integration.md
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
# Laya 引擎集成
|
||||||
|
|
||||||
|
本教程将引导你在 Laya 引擎项目中集成和使用行为树系统。
|
||||||
|
|
||||||
|
## 前置要求
|
||||||
|
|
||||||
|
- LayaAir 3.x 或更高版本
|
||||||
|
- 基本的 TypeScript 知识
|
||||||
|
- 已完成[快速开始](./getting-started.md)教程
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
在你的 Laya 项目根目录下:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/ecs-framework @esengine/behavior-tree
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
建议的项目结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── ai/
|
||||||
|
│ ├── EnemyAI.ts
|
||||||
|
│ └── BossAI.ts
|
||||||
|
├── systems/
|
||||||
|
│ └── AISystem.ts
|
||||||
|
└── Main.ts
|
||||||
|
resources/
|
||||||
|
└── behaviors/
|
||||||
|
├── enemy.btree.json
|
||||||
|
└── boss.btree.json
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 初始化
|
||||||
|
|
||||||
|
### 在Main.ts中初始化
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Core, Scene } from '@esengine/ecs-framework';
|
||||||
|
import { BehaviorTreePlugin } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
export class Main {
|
||||||
|
constructor() {
|
||||||
|
Laya.init(1280, 720).then(() => {
|
||||||
|
this.initECS();
|
||||||
|
this.startGame();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initECS() {
|
||||||
|
// 初始化 ECS
|
||||||
|
Core.create();
|
||||||
|
|
||||||
|
// 安装行为树插件
|
||||||
|
const btPlugin = new BehaviorTreePlugin();
|
||||||
|
await Core.installPlugin(btPlugin);
|
||||||
|
|
||||||
|
// 创建并设置场景
|
||||||
|
const scene = new Scene();
|
||||||
|
btPlugin.setupScene(scene);
|
||||||
|
Core.setScene(scene);
|
||||||
|
|
||||||
|
// 启动更新循环
|
||||||
|
Laya.timer.frameLoop(1, this, this.update);
|
||||||
|
}
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
// Core.update会自动更新场景
|
||||||
|
Core.update(Laya.timer.delta / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startGame() {
|
||||||
|
// 加载场景
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new Main();
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 创建AI组件
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Core, Entity } from '@esengine/ecs-framework';
|
||||||
|
import {
|
||||||
|
BehaviorTreeAssetSerializer,
|
||||||
|
BehaviorTreeAssetLoader,
|
||||||
|
BehaviorTreeStarter,
|
||||||
|
BlackboardComponent
|
||||||
|
} from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
export class EnemyAI extends Laya.Script {
|
||||||
|
behaviorTreePath: string = "resources/behaviors/enemy.btree";
|
||||||
|
|
||||||
|
private aiEntity: Entity;
|
||||||
|
|
||||||
|
onEnable() {
|
||||||
|
this.loadBehaviorTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBehaviorTree() {
|
||||||
|
// 获取Core管理的场景
|
||||||
|
const scene = Core.scene;
|
||||||
|
if (!scene) {
|
||||||
|
console.error('场景未初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载JSON资产
|
||||||
|
const jsonData = await Laya.loader.load(this.behaviorTreePath, Laya.Loader.JSON);
|
||||||
|
|
||||||
|
// 转换为JSON字符串
|
||||||
|
const jsonString = typeof jsonData === 'string' ? jsonData : JSON.stringify(jsonData);
|
||||||
|
|
||||||
|
// 反序列化
|
||||||
|
const asset = BehaviorTreeAssetSerializer.deserialize(jsonString);
|
||||||
|
|
||||||
|
// 实例化
|
||||||
|
this.aiEntity = BehaviorTreeAssetLoader.instantiate(asset, scene, {
|
||||||
|
namePrefix: (this.owner as Laya.Sprite).name
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置黑板变量
|
||||||
|
const blackboard = this.aiEntity.getComponent(BlackboardComponent);
|
||||||
|
blackboard?.setValue('layaSprite', this.owner);
|
||||||
|
blackboard?.setValue('position', {
|
||||||
|
x: (this.owner as Laya.Sprite).x,
|
||||||
|
y: (this.owner as Laya.Sprite).y
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动AI
|
||||||
|
BehaviorTreeStarter.start(this.aiEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDisable() {
|
||||||
|
// 停止AI
|
||||||
|
if (this.aiEntity) {
|
||||||
|
BehaviorTreeStarter.stop(this.aiEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 与Laya节点交互
|
||||||
|
|
||||||
|
在BehaviorTreeBuilder的action方法中,可以直接操作Laya节点。下面的完整示例展示了如何实现。
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
创建一个完整的敌人AI系统:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BehaviorTreeBuilder, BehaviorTreeStarter, BlackboardValueType, TaskStatus } from '@esengine/behavior-tree';
|
||||||
|
import { Core, Entity } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
export class SimpleEnemyAI extends Laya.Script {
|
||||||
|
public player: Laya.Sprite;
|
||||||
|
public patrolPoints: Array<{x: number, y: number}> = [];
|
||||||
|
|
||||||
|
private aiEntity: Entity;
|
||||||
|
|
||||||
|
onEnable() {
|
||||||
|
this.buildAI();
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAI() {
|
||||||
|
const scene = Core.scene;
|
||||||
|
if (!scene) {
|
||||||
|
console.error('场景未初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sprite = this.owner as Laya.Sprite;
|
||||||
|
|
||||||
|
this.aiEntity = BehaviorTreeBuilder.create(scene, 'EnemyAI')
|
||||||
|
.blackboard()
|
||||||
|
.defineVariable('sprite', BlackboardValueType.Object, sprite)
|
||||||
|
.defineVariable('health', BlackboardValueType.Number, 100)
|
||||||
|
.defineVariable('player', BlackboardValueType.Object, this.player)
|
||||||
|
.defineVariable('patrolIndex', BlackboardValueType.Number, 0)
|
||||||
|
.endBlackboard()
|
||||||
|
.selector()
|
||||||
|
// 攻击玩家
|
||||||
|
.sequence()
|
||||||
|
.condition((e, bb) => {
|
||||||
|
const player = bb?.getValue('player');
|
||||||
|
if (!player) return false;
|
||||||
|
|
||||||
|
const dx = player.x - sprite.x;
|
||||||
|
const dy = player.y - sprite.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
return distance < 200; // 检测范围
|
||||||
|
}, 'CheckPlayerInRange')
|
||||||
|
.action('Attack', (e, bb) => {
|
||||||
|
console.log('攻击玩家');
|
||||||
|
// 攻击逻辑
|
||||||
|
return TaskStatus.Success;
|
||||||
|
})
|
||||||
|
.end()
|
||||||
|
// 巡逻
|
||||||
|
.sequence()
|
||||||
|
.action('Patrol', (e, bb, dt) => {
|
||||||
|
const index = bb?.getValue('patrolIndex') || 0;
|
||||||
|
const point = this.patrolPoints[index];
|
||||||
|
|
||||||
|
const dx = point.x - sprite.x;
|
||||||
|
const dy = point.y - sprite.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (distance < 10) {
|
||||||
|
// 到达路点
|
||||||
|
const nextIndex = (index + 1) % this.patrolPoints.length;
|
||||||
|
bb?.setValue('patrolIndex', nextIndex);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动
|
||||||
|
const speed = 50;
|
||||||
|
sprite.x += (dx / distance) * speed * dt;
|
||||||
|
sprite.y += (dy / distance) * speed * dt;
|
||||||
|
|
||||||
|
return TaskStatus.Running;
|
||||||
|
})
|
||||||
|
.wait(2.0)
|
||||||
|
.end()
|
||||||
|
.end()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
BehaviorTreeStarter.start(this.aiEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDisable() {
|
||||||
|
// 停止AI
|
||||||
|
if (this.aiEntity) {
|
||||||
|
BehaviorTreeStarter.stop(this.aiEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
### 使用对象池
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class AIPool {
|
||||||
|
private pool: Entity[] = [];
|
||||||
|
|
||||||
|
get(asset: any, scene: Scene): Entity {
|
||||||
|
return this.pool.pop() ||
|
||||||
|
BehaviorTreeAssetLoader.instantiate(asset, scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
release(entity: Entity) {
|
||||||
|
BehaviorTreeStarter.stop(entity);
|
||||||
|
this.pool.push(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 降低更新频率
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private updateInterval: number = 0.1; // 每0.1秒更新
|
||||||
|
private timer: number = 0;
|
||||||
|
|
||||||
|
onUpdate() {
|
||||||
|
this.timer += Laya.timer.delta / 1000;
|
||||||
|
|
||||||
|
if (this.timer >= this.updateInterval) {
|
||||||
|
this.scene?.update();
|
||||||
|
this.timer = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 资源加载失败?
|
||||||
|
|
||||||
|
确保:
|
||||||
|
1. 资源路径正确
|
||||||
|
2. 资源已添加到项目中
|
||||||
|
3. 使用 `Laya.loader.load()` 加载
|
||||||
|
|
||||||
|
### AI不执行?
|
||||||
|
|
||||||
|
检查:
|
||||||
|
1. `onUpdate()` 是否被调用
|
||||||
|
2. `Scene.update()` 是否执行
|
||||||
|
3. 行为树是否已启动
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 查看[高级用法](./advanced-usage.md)
|
||||||
|
- 学习[最佳实践](./best-practices.md)
|
||||||
0
docs/guide/editor-plugin-system.md
Normal file
0
docs/guide/editor-plugin-system.md
Normal file
296
package-lock.json
generated
296
package-lock.json
generated
@@ -7964,6 +7964,242 @@
|
|||||||
"size-limit": "11.2.0"
|
"size-limit": "11.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/core": {
|
||||||
|
"version": "1.13.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz",
|
||||||
|
"integrity": "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3",
|
||||||
|
"@swc/types": "^0.1.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/swc"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@swc/core-darwin-arm64": "1.13.5",
|
||||||
|
"@swc/core-darwin-x64": "1.13.5",
|
||||||
|
"@swc/core-linux-arm-gnueabihf": "1.13.5",
|
||||||
|
"@swc/core-linux-arm64-gnu": "1.13.5",
|
||||||
|
"@swc/core-linux-arm64-musl": "1.13.5",
|
||||||
|
"@swc/core-linux-x64-gnu": "1.13.5",
|
||||||
|
"@swc/core-linux-x64-musl": "1.13.5",
|
||||||
|
"@swc/core-win32-arm64-msvc": "1.13.5",
|
||||||
|
"@swc/core-win32-ia32-msvc": "1.13.5",
|
||||||
|
"@swc/core-win32-x64-msvc": "1.13.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@swc/helpers": ">=0.5.17"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@swc/helpers": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-darwin-arm64": {
|
||||||
|
"version": "1.13.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.5.tgz",
|
||||||
|
"integrity": "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-darwin-x64": {
|
||||||
|
"version": "1.13.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.5.tgz",
|
||||||
|
"integrity": "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.13.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.5.tgz",
|
||||||
|
"integrity": "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||||
|
"version": "1.13.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.5.tgz",
|
||||||
|
"integrity": "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-musl": {
|
||||||
|
"version": "1.13.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.5.tgz",
|
||||||
|
"integrity": "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-gnu": {
|
||||||
|
"version": "1.13.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.5.tgz",
|
||||||
|
"integrity": "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-musl": {
|
||||||
|
"version": "1.13.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.5.tgz",
|
||||||
|
"integrity": "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||||
|
"version": "1.13.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.5.tgz",
|
||||||
|
"integrity": "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||||
|
"version": "1.13.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.5.tgz",
|
||||||
|
"integrity": "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-x64-msvc": {
|
||||||
|
"version": "1.13.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.5.tgz",
|
||||||
|
"integrity": "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/counter": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@swc/types": {
|
||||||
|
"version": "0.1.25",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz",
|
||||||
|
"integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/api": {
|
"node_modules/@tauri-apps/api": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.8.0.tgz",
|
||||||
@@ -8931,6 +9167,30 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitejs/plugin-react-swc": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-/tesahXD1qpkGC6FzMoFOJj0RyZdw9xLELOL+6jbElwmWfwOnIVy+IfpY+o9JfD9PKaR/Eyb6DNrvbXpuvA+8Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rolldown/pluginutils": "1.0.0-beta.43",
|
||||||
|
"@swc/core": "^1.13.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^4 || ^5 || ^6 || ^7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitejs/plugin-react-swc/node_modules/@rolldown/pluginutils": {
|
||||||
|
"version": "1.0.0-beta.43",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz",
|
||||||
|
"integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "5.2.4",
|
"version": "5.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||||
@@ -25445,6 +25705,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-swc-transform": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-plugin-swc-transform/-/vite-plugin-swc-transform-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-uef69pFsfSQTPM95ubXzhqKevWQWCAUh/VQ/FW7IsEgjDkK/OJOXrdmuCSQT0VHZPH6wyhO3I1PWkV4lHK2IZA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/pluginutils": "^5.1.4",
|
||||||
|
"@swc/core": "^1.10.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^7 || ^6 || ^5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vitepress": {
|
"node_modules/vitepress": {
|
||||||
"version": "1.6.4",
|
"version": "1.6.4",
|
||||||
"resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz",
|
||||||
@@ -26077,14 +26354,17 @@
|
|||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@swc/core": "^1.13.5",
|
||||||
"@tauri-apps/cli": "^2.2.0",
|
"@tauri-apps/cli": "^2.2.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.2.0",
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7",
|
||||||
|
"vite-plugin-swc-transform": "^1.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/editor-app/node_modules/@esbuild/aix-ppc64": {
|
"packages/editor-app/node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -26617,16 +26897,28 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@esengine/ecs-framework": "file:../core",
|
|
||||||
"tslib": "^2.8.1"
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.28.3",
|
||||||
|
"@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1",
|
||||||
|
"@babel/plugin-transform-optional-chaining": "^7.27.1",
|
||||||
|
"@babel/preset-env": "^7.28.3",
|
||||||
|
"@rollup/plugin-babel": "^6.0.4",
|
||||||
|
"@rollup/plugin-commonjs": "^28.0.3",
|
||||||
|
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||||
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^20.19.17",
|
"@types/node": "^20.19.17",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"rimraf": "^5.0.0",
|
"rimraf": "^5.0.0",
|
||||||
|
"rollup": "^4.42.0",
|
||||||
|
"rollup-plugin-dts": "^6.2.1",
|
||||||
"ts-jest": "^29.4.0",
|
"ts-jest": "^29.4.0",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@esengine/ecs-framework": "^2.2.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/math": {
|
"packages/math": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NodeTemplate, PropertyDefinition } from '../Serialization/NodeTemplates';
|
import { NodeTemplate, PropertyDefinition } from '../Serialization/NodeTemplates';
|
||||||
import { NodeType } from '../Types/TaskStatus';
|
import { NodeType } from '../Types/TaskStatus';
|
||||||
|
import { getComponentTypeName } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 行为树节点元数据
|
* 行为树节点元数据
|
||||||
@@ -80,7 +81,7 @@ export function BehaviorNode(metadata: BehaviorNodeMetadata) {
|
|||||||
return function <T extends { new (...args: any[]): any }>(constructor: T) {
|
return function <T extends { new (...args: any[]): any }>(constructor: T) {
|
||||||
const metadataWithClassName = {
|
const metadataWithClassName = {
|
||||||
...metadata,
|
...metadata,
|
||||||
className: constructor.name
|
className: getComponentTypeName(constructor as any)
|
||||||
};
|
};
|
||||||
NodeClassRegistry.registerNodeClass(constructor, metadataWithClassName);
|
NodeClassRegistry.registerNodeClass(constructor, metadataWithClassName);
|
||||||
return constructor;
|
return constructor;
|
||||||
@@ -129,14 +130,12 @@ export const NodeProperty = BehaviorProperty;
|
|||||||
*/
|
*/
|
||||||
export function getRegisteredNodeTemplates(): NodeTemplate[] {
|
export function getRegisteredNodeTemplates(): NodeTemplate[] {
|
||||||
return NodeClassRegistry.getAllNodeClasses().map(({ metadata, constructor }) => {
|
return NodeClassRegistry.getAllNodeClasses().map(({ metadata, constructor }) => {
|
||||||
// 从类的 __nodeProperties 收集属性定义
|
|
||||||
const propertyDefs = constructor.__nodeProperties || [];
|
const propertyDefs = constructor.__nodeProperties || [];
|
||||||
|
|
||||||
const defaultConfig: any = {
|
const defaultConfig: any = {
|
||||||
nodeType: metadata.type.toLowerCase()
|
nodeType: metadata.type.toLowerCase()
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从类的默认值中提取配置,并补充 defaultValue
|
|
||||||
const instance = new constructor();
|
const instance = new constructor();
|
||||||
const properties: PropertyDefinition[] = propertyDefs.map((prop: PropertyDefinition) => {
|
const properties: PropertyDefinition[] = propertyDefs.map((prop: PropertyDefinition) => {
|
||||||
const defaultValue = instance[prop.name];
|
const defaultValue = instance[prop.name];
|
||||||
@@ -149,7 +148,6 @@ export function getRegisteredNodeTemplates(): NodeTemplate[] {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 添加子类型字段
|
|
||||||
switch (metadata.type) {
|
switch (metadata.type) {
|
||||||
case NodeType.Composite:
|
case NodeType.Composite:
|
||||||
defaultConfig.compositeType = metadata.displayName;
|
defaultConfig.compositeType = metadata.displayName;
|
||||||
@@ -173,6 +171,7 @@ export function getRegisteredNodeTemplates(): NodeTemplate[] {
|
|||||||
description: metadata.description,
|
description: metadata.description,
|
||||||
color: metadata.color,
|
color: metadata.color,
|
||||||
className: metadata.className,
|
className: metadata.className,
|
||||||
|
componentClass: constructor,
|
||||||
requiresChildren: metadata.requiresChildren,
|
requiresChildren: metadata.requiresChildren,
|
||||||
defaultConfig,
|
defaultConfig,
|
||||||
properties
|
properties
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Entity, IScene, createLogger } from '@esengine/ecs-framework';
|
import { Entity, IScene, createLogger, ComponentRegistry, Component } from '@esengine/ecs-framework';
|
||||||
import type { BehaviorTreeAsset, BehaviorTreeNodeData, BlackboardVariableDefinition, PropertyBinding } from './BehaviorTreeAsset';
|
import type { BehaviorTreeAsset, BehaviorTreeNodeData, BlackboardVariableDefinition, PropertyBinding } from './BehaviorTreeAsset';
|
||||||
import { BehaviorTreeNode } from '../Components/BehaviorTreeNode';
|
import { BehaviorTreeNode } from '../Components/BehaviorTreeNode';
|
||||||
import { BlackboardComponent } from '../Components/BlackboardComponent';
|
import { BlackboardComponent } from '../Components/BlackboardComponent';
|
||||||
@@ -306,6 +306,19 @@ export class BehaviorTreeAssetLoader {
|
|||||||
} else if (nameLower.includes('execute') || nameLower.includes('自定义')) {
|
} else if (nameLower.includes('execute') || nameLower.includes('自定义')) {
|
||||||
const action = entity.addComponent(new ExecuteAction());
|
const action = entity.addComponent(new ExecuteAction());
|
||||||
action.actionCode = data.actionCode ?? 'return TaskStatus.Success;';
|
action.actionCode = data.actionCode ?? 'return TaskStatus.Success;';
|
||||||
|
} else if (data.className) {
|
||||||
|
const ComponentClass = ComponentRegistry.getComponentType(data.className);
|
||||||
|
if (ComponentClass) {
|
||||||
|
try {
|
||||||
|
const component = new (ComponentClass as any)();
|
||||||
|
Object.assign(component, data);
|
||||||
|
entity.addComponent(component as Component);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`创建动作组件失败: ${data.className}, error: ${error}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`未找到动作组件类: ${data.className}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`未知的动作类型: ${name}`);
|
logger.warn(`未知的动作类型: ${name}`);
|
||||||
}
|
}
|
||||||
@@ -335,6 +348,19 @@ export class BehaviorTreeAssetLoader {
|
|||||||
const condition = entity.addComponent(new ExecuteCondition());
|
const condition = entity.addComponent(new ExecuteCondition());
|
||||||
condition.conditionCode = data.conditionCode ?? '';
|
condition.conditionCode = data.conditionCode ?? '';
|
||||||
condition.invertResult = data.invertResult ?? false;
|
condition.invertResult = data.invertResult ?? false;
|
||||||
|
} else if (data.className) {
|
||||||
|
const ComponentClass = ComponentRegistry.getComponentType(data.className);
|
||||||
|
if (ComponentClass) {
|
||||||
|
try {
|
||||||
|
const component = new (ComponentClass as any)();
|
||||||
|
Object.assign(component, data);
|
||||||
|
entity.addComponent(component as Component);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`创建条件组件失败: ${data.className}, error: ${error}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`未找到条件组件类: ${data.className}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`未知的条件类型: ${name}`);
|
logger.warn(`未知的条件类型: ${name}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,13 +67,11 @@ export class EditorFormatConverter {
|
|||||||
static toAsset(editorData: EditorFormat, metadata?: Partial<AssetMetadata>): BehaviorTreeAsset {
|
static toAsset(editorData: EditorFormat, metadata?: Partial<AssetMetadata>): BehaviorTreeAsset {
|
||||||
logger.info('开始转换编辑器格式到资产格式');
|
logger.info('开始转换编辑器格式到资产格式');
|
||||||
|
|
||||||
// 查找根节点
|
|
||||||
const rootNode = this.findRootNode(editorData.nodes);
|
const rootNode = this.findRootNode(editorData.nodes);
|
||||||
if (!rootNode) {
|
if (!rootNode) {
|
||||||
throw new Error('未找到根节点');
|
throw new Error('未找到根节点');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换元数据
|
|
||||||
const assetMetadata: AssetMetadata = {
|
const assetMetadata: AssetMetadata = {
|
||||||
name: metadata?.name || editorData.metadata?.name || 'Untitled Behavior Tree',
|
name: metadata?.name || editorData.metadata?.name || 'Untitled Behavior Tree',
|
||||||
description: metadata?.description || editorData.metadata?.description,
|
description: metadata?.description || editorData.metadata?.description,
|
||||||
@@ -82,13 +80,10 @@ export class EditorFormatConverter {
|
|||||||
modifiedAt: metadata?.modifiedAt || new Date().toISOString()
|
modifiedAt: metadata?.modifiedAt || new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
// 转换节点
|
|
||||||
const nodes = this.convertNodes(editorData.nodes);
|
const nodes = this.convertNodes(editorData.nodes);
|
||||||
|
|
||||||
// 转换黑板
|
|
||||||
const blackboard = this.convertBlackboard(editorData.blackboard);
|
const blackboard = this.convertBlackboard(editorData.blackboard);
|
||||||
|
|
||||||
// 转换属性绑定
|
|
||||||
const propertyBindings = this.convertPropertyBindings(
|
const propertyBindings = this.convertPropertyBindings(
|
||||||
editorData.connections,
|
editorData.connections,
|
||||||
editorData.nodes,
|
editorData.nodes,
|
||||||
@@ -130,11 +125,13 @@ export class EditorFormatConverter {
|
|||||||
* 转换单个节点
|
* 转换单个节点
|
||||||
*/
|
*/
|
||||||
private static convertNode(editorNode: EditorNode): BehaviorTreeNodeData {
|
private static convertNode(editorNode: EditorNode): BehaviorTreeNodeData {
|
||||||
// 复制data,去除编辑器特有的字段
|
|
||||||
const data = { ...editorNode.data };
|
const data = { ...editorNode.data };
|
||||||
|
|
||||||
// 移除可能存在的UI相关字段
|
delete data.nodeType;
|
||||||
delete data.nodeType; // 这个信息已经在nodeType字段中
|
|
||||||
|
if (editorNode.template.className) {
|
||||||
|
data.className = editorNode.template.className;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: editorNode.id,
|
id: editorNode.id,
|
||||||
@@ -152,7 +149,6 @@ export class EditorFormatConverter {
|
|||||||
const variables: BlackboardVariableDefinition[] = [];
|
const variables: BlackboardVariableDefinition[] = [];
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(blackboard)) {
|
for (const [name, value] of Object.entries(blackboard)) {
|
||||||
// 推断类型
|
|
||||||
const type = this.inferBlackboardType(value);
|
const type = this.inferBlackboardType(value);
|
||||||
|
|
||||||
variables.push({
|
variables.push({
|
||||||
@@ -191,7 +187,6 @@ export class EditorFormatConverter {
|
|||||||
const bindings: PropertyBinding[] = [];
|
const bindings: PropertyBinding[] = [];
|
||||||
const blackboardVarNames = new Set(blackboard.map(v => v.name));
|
const blackboardVarNames = new Set(blackboard.map(v => v.name));
|
||||||
|
|
||||||
// 只处理属性类型的连接
|
|
||||||
const propertyConnections = connections.filter(conn => conn.connectionType === 'property');
|
const propertyConnections = connections.filter(conn => conn.connectionType === 'property');
|
||||||
|
|
||||||
for (const conn of propertyConnections) {
|
for (const conn of propertyConnections) {
|
||||||
@@ -205,7 +200,6 @@ export class EditorFormatConverter {
|
|||||||
|
|
||||||
let variableName: string | undefined;
|
let variableName: string | undefined;
|
||||||
|
|
||||||
// 检查 from 节点是否是黑板变量节点
|
|
||||||
if (fromNode.data.nodeType === 'blackboard-variable') {
|
if (fromNode.data.nodeType === 'blackboard-variable') {
|
||||||
variableName = fromNode.data.variableName;
|
variableName = fromNode.data.variableName;
|
||||||
} else if (conn.fromProperty) {
|
} else if (conn.fromProperty) {
|
||||||
@@ -241,22 +235,18 @@ export class EditorFormatConverter {
|
|||||||
static fromAsset(asset: BehaviorTreeAsset): EditorFormat {
|
static fromAsset(asset: BehaviorTreeAsset): EditorFormat {
|
||||||
logger.info('开始转换资产格式到编辑器格式');
|
logger.info('开始转换资产格式到编辑器格式');
|
||||||
|
|
||||||
// 转换节点
|
|
||||||
const nodes = this.convertNodesFromAsset(asset.nodes);
|
const nodes = this.convertNodesFromAsset(asset.nodes);
|
||||||
|
|
||||||
// 转换黑板
|
|
||||||
const blackboard: Record<string, any> = {};
|
const blackboard: Record<string, any> = {};
|
||||||
for (const variable of asset.blackboard) {
|
for (const variable of asset.blackboard) {
|
||||||
blackboard[variable.name] = variable.defaultValue;
|
blackboard[variable.name] = variable.defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换属性绑定为连接
|
|
||||||
const connections = this.convertPropertyBindingsToConnections(
|
const connections = this.convertPropertyBindingsToConnections(
|
||||||
asset.propertyBindings || [],
|
asset.propertyBindings || [],
|
||||||
asset.nodes
|
asset.nodes
|
||||||
);
|
);
|
||||||
|
|
||||||
// 添加节点连接(基于children关系)
|
|
||||||
const nodeConnections = this.buildNodeConnections(asset.nodes);
|
const nodeConnections = this.buildNodeConnections(asset.nodes);
|
||||||
connections.push(...nodeConnections);
|
connections.push(...nodeConnections);
|
||||||
|
|
||||||
@@ -287,19 +277,24 @@ export class EditorFormatConverter {
|
|||||||
*/
|
*/
|
||||||
private static convertNodesFromAsset(assetNodes: BehaviorTreeNodeData[]): EditorNode[] {
|
private static convertNodesFromAsset(assetNodes: BehaviorTreeNodeData[]): EditorNode[] {
|
||||||
return assetNodes.map((node, index) => {
|
return assetNodes.map((node, index) => {
|
||||||
// 简单的自动布局:按索引计算位置
|
|
||||||
const position = {
|
const position = {
|
||||||
x: 100 + (index % 5) * 250,
|
x: 100 + (index % 5) * 250,
|
||||||
y: 100 + Math.floor(index / 5) * 150
|
y: 100 + Math.floor(index / 5) * 150
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const template: any = {
|
||||||
|
displayName: node.name,
|
||||||
|
category: this.inferCategory(node.nodeType),
|
||||||
|
type: node.nodeType
|
||||||
|
};
|
||||||
|
|
||||||
|
if (node.data.className) {
|
||||||
|
template.className = node.data.className;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
template: {
|
template,
|
||||||
displayName: node.name,
|
|
||||||
category: this.inferCategory(node.nodeType),
|
|
||||||
type: node.nodeType
|
|
||||||
},
|
|
||||||
data: { ...node.data },
|
data: { ...node.data },
|
||||||
position,
|
position,
|
||||||
children: node.children
|
children: node.children
|
||||||
@@ -335,10 +330,8 @@ export class EditorFormatConverter {
|
|||||||
const connections: EditorConnection[] = [];
|
const connections: EditorConnection[] = [];
|
||||||
|
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
// 需要找到代表这个黑板变量的节点(如果有的话)
|
|
||||||
// 这里简化处理,在实际使用中可能需要更复杂的逻辑
|
|
||||||
connections.push({
|
connections.push({
|
||||||
from: 'blackboard', // 占位符,实际使用时需要更复杂的处理
|
from: 'blackboard',
|
||||||
to: binding.nodeId,
|
to: binding.nodeId,
|
||||||
toProperty: binding.propertyName,
|
toProperty: binding.propertyName,
|
||||||
connectionType: 'property'
|
connectionType: 'property'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { NodeType } from '../Types/TaskStatus';
|
|||||||
import { getRegisteredNodeTemplates } from '../Decorators/BehaviorNodeDecorator';
|
import { getRegisteredNodeTemplates } from '../Decorators/BehaviorNodeDecorator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节点数据JSON格式(用于编辑器)
|
* 节点数据JSON格式
|
||||||
*/
|
*/
|
||||||
export interface NodeDataJSON {
|
export interface NodeDataJSON {
|
||||||
nodeType: string;
|
nodeType: string;
|
||||||
@@ -11,12 +11,49 @@ export interface NodeDataJSON {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内置属性类型常量
|
||||||
|
*/
|
||||||
|
export const PropertyType = {
|
||||||
|
/** 字符串 */
|
||||||
|
String: 'string',
|
||||||
|
/** 数值 */
|
||||||
|
Number: 'number',
|
||||||
|
/** 布尔值 */
|
||||||
|
Boolean: 'boolean',
|
||||||
|
/** 选择框 */
|
||||||
|
Select: 'select',
|
||||||
|
/** 黑板变量引用 */
|
||||||
|
Blackboard: 'blackboard',
|
||||||
|
/** 代码编辑器 */
|
||||||
|
Code: 'code',
|
||||||
|
/** 变量引用 */
|
||||||
|
Variable: 'variable',
|
||||||
|
/** 资产引用 */
|
||||||
|
Asset: 'asset'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 属性类型(支持自定义扩展)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // 使用内置类型
|
||||||
|
* type: PropertyType.String
|
||||||
|
*
|
||||||
|
* // 使用自定义类型
|
||||||
|
* type: 'color-picker'
|
||||||
|
* type: 'curve-editor'
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export type PropertyType = typeof PropertyType[keyof typeof PropertyType] | string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 属性定义(用于编辑器)
|
* 属性定义(用于编辑器)
|
||||||
*/
|
*/
|
||||||
export interface PropertyDefinition {
|
export interface PropertyDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'string' | 'number' | 'boolean' | 'select' | 'blackboard' | 'code' | 'variable' | 'asset';
|
type: PropertyType;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
defaultValue?: any;
|
defaultValue?: any;
|
||||||
@@ -25,6 +62,62 @@ export interface PropertyDefinition {
|
|||||||
max?: number;
|
max?: number;
|
||||||
step?: number;
|
step?: number;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义渲染配置
|
||||||
|
*
|
||||||
|
* 用于指定编辑器如何渲染此属性
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* renderConfig: {
|
||||||
|
* component: 'ColorPicker', // 渲染器组件名称
|
||||||
|
* props: { // 传递给组件的属性
|
||||||
|
* showAlpha: true,
|
||||||
|
* presets: ['#FF0000', '#00FF00']
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
renderConfig?: {
|
||||||
|
/** 渲染器组件名称或路径 */
|
||||||
|
component?: string;
|
||||||
|
/** 传递给渲染器的属性配置 */
|
||||||
|
props?: Record<string, any>;
|
||||||
|
/** 渲染器的样式类名 */
|
||||||
|
className?: string;
|
||||||
|
/** 渲染器的内联样式 */
|
||||||
|
style?: Record<string, any>;
|
||||||
|
/** 其他自定义配置 */
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证规则
|
||||||
|
*
|
||||||
|
* 用于在编辑器中验证输入
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* validation: {
|
||||||
|
* pattern: /^\d+$/,
|
||||||
|
* message: '只能输入数字',
|
||||||
|
* validator: (value) => value > 0
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
validation?: {
|
||||||
|
/** 正则表达式验证 */
|
||||||
|
pattern?: RegExp | string;
|
||||||
|
/** 验证失败的提示信息 */
|
||||||
|
message?: string;
|
||||||
|
/** 自定义验证函数 */
|
||||||
|
validator?: string; // 函数字符串,编辑器会解析
|
||||||
|
/** 最小长度(字符串) */
|
||||||
|
minLength?: number;
|
||||||
|
/** 最大长度(字符串) */
|
||||||
|
maxLength?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +131,7 @@ export interface NodeTemplate {
|
|||||||
description: string;
|
description: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
componentClass?: Function;
|
||||||
requiresChildren?: boolean;
|
requiresChildren?: boolean;
|
||||||
defaultConfig: Partial<NodeDataJSON>;
|
defaultConfig: Partial<NodeDataJSON>;
|
||||||
properties: PropertyDefinition[];
|
properties: PropertyDefinition[];
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ export class LeafExecutionSystem extends EntitySystem {
|
|||||||
} else if (entity.hasComponent(ExecuteAction)) {
|
} else if (entity.hasComponent(ExecuteAction)) {
|
||||||
status = this.executeCustomAction(entity);
|
status = this.executeCustomAction(entity);
|
||||||
} else {
|
} else {
|
||||||
this.outputLog(entity, `动作节点没有找到任何已知的动作组件`, 'warn');
|
status = this.executeGenericAction(entity);
|
||||||
|
if (status === TaskStatus.Failure) {
|
||||||
|
this.outputLog(entity, `动作节点没有找到任何已知的动作组件`, 'warn');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
node.status = status;
|
node.status = status;
|
||||||
@@ -298,6 +301,41 @@ export class LeafExecutionSystem extends EntitySystem {
|
|||||||
return func(entity, blackboard, Time.deltaTime);
|
return func(entity, blackboard, Time.deltaTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行通用动作组件
|
||||||
|
* 查找实体上具有 execute 方法的自定义组件并执行
|
||||||
|
*/
|
||||||
|
private executeGenericAction(entity: Entity): TaskStatus {
|
||||||
|
for (const component of entity.components) {
|
||||||
|
if (component instanceof BehaviorTreeNode ||
|
||||||
|
component instanceof ActiveNode ||
|
||||||
|
component instanceof BlackboardComponent ||
|
||||||
|
component instanceof PropertyBindings ||
|
||||||
|
component instanceof LogOutput) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof (component as any).execute === 'function') {
|
||||||
|
try {
|
||||||
|
const blackboard = this.findBlackboard(entity);
|
||||||
|
const status = (component as any).execute(entity, blackboard);
|
||||||
|
|
||||||
|
if (typeof status === 'number' &&
|
||||||
|
(status === TaskStatus.Success ||
|
||||||
|
status === TaskStatus.Failure ||
|
||||||
|
status === TaskStatus.Running)) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.outputLog(entity, `执行动作组件时发生错误: ${error}`, 'error');
|
||||||
|
return TaskStatus.Failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TaskStatus.Failure;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行条件节点
|
* 执行条件节点
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,18 +13,34 @@ export enum TaskStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节点类型
|
* 内置节点类型常量
|
||||||
*/
|
*/
|
||||||
export enum NodeType {
|
export const NodeType = {
|
||||||
/** 复合节点 - 有多个子节点 */
|
/** 复合节点 - 有多个子节点 */
|
||||||
Composite = 'composite',
|
Composite: 'composite',
|
||||||
/** 装饰器节点 - 有一个子节点 */
|
/** 装饰器节点 - 有一个子节点 */
|
||||||
Decorator = 'decorator',
|
Decorator: 'decorator',
|
||||||
/** 动作节点 - 叶子节点 */
|
/** 动作节点 - 叶子节点 */
|
||||||
Action = 'action',
|
Action: 'action',
|
||||||
/** 条件节点 - 叶子节点 */
|
/** 条件节点 - 叶子节点 */
|
||||||
Condition = 'condition'
|
Condition: 'condition'
|
||||||
}
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点类型(支持自定义扩展)
|
||||||
|
*
|
||||||
|
* 使用内置类型或自定义字符串
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // 使用内置类型
|
||||||
|
* type: NodeType.Action
|
||||||
|
*
|
||||||
|
* // 使用自定义类型
|
||||||
|
* type: 'custom-behavior'
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export type NodeType = typeof NodeType[keyof typeof NodeType] | string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复合节点类型
|
* 复合节点类型
|
||||||
|
|||||||
18
packages/editor-app/.swcrc
Normal file
18
packages/editor-app/.swcrc
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"jsc": {
|
||||||
|
"parser": {
|
||||||
|
"syntax": "typescript",
|
||||||
|
"tsx": true,
|
||||||
|
"decorators": true
|
||||||
|
},
|
||||||
|
"transform": {
|
||||||
|
"legacyDecorator": true,
|
||||||
|
"decoratorMetadata": true,
|
||||||
|
"useDefineForClassFields": false,
|
||||||
|
"react": {
|
||||||
|
"runtime": "automatic"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"target": "es2020"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,13 +32,16 @@
|
|||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@swc/core": "^1.13.5",
|
||||||
"@tauri-apps/cli": "^2.2.0",
|
"@tauri-apps/cli": "^2.2.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.2.0",
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7",
|
||||||
|
"vite-plugin-swc-transform": "^1.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { AboutDialog } from './components/AboutDialog';
|
|||||||
import { ErrorDialog } from './components/ErrorDialog';
|
import { ErrorDialog } from './components/ErrorDialog';
|
||||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||||
import { BehaviorTreeWindow } from './components/BehaviorTreeWindow';
|
import { BehaviorTreeWindow } from './components/BehaviorTreeWindow';
|
||||||
|
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
||||||
import { ToastProvider } from './components/Toast';
|
import { ToastProvider } from './components/Toast';
|
||||||
import { Viewport } from './components/Viewport';
|
import { Viewport } from './components/Viewport';
|
||||||
import { MenuBar } from './components/MenuBar';
|
import { MenuBar } from './components/MenuBar';
|
||||||
@@ -27,6 +28,7 @@ import { FlexLayoutDockContainer, FlexDockPanel } from './components/FlexLayoutD
|
|||||||
import { TauriAPI } from './api/tauri';
|
import { TauriAPI } from './api/tauri';
|
||||||
import { TauriFileAPI } from './adapters/TauriFileAPI';
|
import { TauriFileAPI } from './adapters/TauriFileAPI';
|
||||||
import { SettingsService } from './services/SettingsService';
|
import { SettingsService } from './services/SettingsService';
|
||||||
|
import { PluginLoader } from './services/PluginLoader';
|
||||||
import { checkForUpdatesOnStartup } from './utils/updater';
|
import { checkForUpdatesOnStartup } from './utils/updater';
|
||||||
import { useLocale } from './hooks/useLocale';
|
import { useLocale } from './hooks/useLocale';
|
||||||
import { en, zh } from './locales';
|
import { en, zh } from './locales';
|
||||||
@@ -45,6 +47,7 @@ Core.services.registerSingleton(GlobalBlackboardService);
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const initRef = useRef(false);
|
const initRef = useRef(false);
|
||||||
|
const pluginLoaderRef = useRef<PluginLoader>(new PluginLoader());
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
const [projectLoaded, setProjectLoaded] = useState(false);
|
const [projectLoaded, setProjectLoaded] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -67,6 +70,7 @@ function App() {
|
|||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
const [showBehaviorTreeEditor, setShowBehaviorTreeEditor] = useState(false);
|
const [showBehaviorTreeEditor, setShowBehaviorTreeEditor] = useState(false);
|
||||||
const [behaviorTreeFilePath, setBehaviorTreeFilePath] = useState<string | null>(null);
|
const [behaviorTreeFilePath, setBehaviorTreeFilePath] = useState<string | null>(null);
|
||||||
|
const [showPluginGenerator, setShowPluginGenerator] = useState(false);
|
||||||
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
|
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
|
||||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||||
const [isProfilerMode, setIsProfilerMode] = useState(false);
|
const [isProfilerMode, setIsProfilerMode] = useState(false);
|
||||||
@@ -274,6 +278,12 @@ function App() {
|
|||||||
|
|
||||||
setCurrentProjectPath(projectPath);
|
setCurrentProjectPath(projectPath);
|
||||||
setProjectLoaded(true);
|
setProjectLoaded(true);
|
||||||
|
|
||||||
|
if (pluginManager) {
|
||||||
|
setLoadingMessage(locale === 'zh' ? '加载项目插件...' : 'Loading project plugins...');
|
||||||
|
await pluginLoaderRef.current.loadProjectPlugins(projectPath, pluginManager);
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to open project:', error);
|
console.error('Failed to open project:', error);
|
||||||
@@ -486,7 +496,10 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseProject = () => {
|
const handleCloseProject = async () => {
|
||||||
|
if (pluginManager) {
|
||||||
|
await pluginLoaderRef.current.unloadProjectPlugins(pluginManager);
|
||||||
|
}
|
||||||
setProjectLoaded(false);
|
setProjectLoaded(false);
|
||||||
setCurrentProjectPath(null);
|
setCurrentProjectPath(null);
|
||||||
setIsProfilerMode(false);
|
setIsProfilerMode(false);
|
||||||
@@ -514,6 +527,10 @@ function App() {
|
|||||||
setShowAbout(true);
|
setShowAbout(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreatePlugin = () => {
|
||||||
|
setShowPluginGenerator(true);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) {
|
if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) {
|
||||||
let corePanels: FlexDockPanel[];
|
let corePanels: FlexDockPanel[];
|
||||||
@@ -675,6 +692,7 @@ function App() {
|
|||||||
onOpenSettings={() => setShowSettings(true)}
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
onToggleDevtools={handleToggleDevtools}
|
onToggleDevtools={handleToggleDevtools}
|
||||||
onOpenAbout={handleOpenAbout}
|
onOpenAbout={handleOpenAbout}
|
||||||
|
onCreatePlugin={handleCreatePlugin}
|
||||||
/>
|
/>
|
||||||
<div className="header-right">
|
<div className="header-right">
|
||||||
<button onClick={handleLocaleChange} className="toolbar-btn locale-btn" title={locale === 'en' ? '切换到中文' : 'Switch to English'}>
|
<button onClick={handleLocaleChange} className="toolbar-btn locale-btn" title={locale === 'en' ? '切换到中文' : 'Switch to English'}>
|
||||||
@@ -729,6 +747,14 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showPluginGenerator && (
|
||||||
|
<PluginGeneratorWindow
|
||||||
|
onClose={() => setShowPluginGenerator(false)}
|
||||||
|
projectPath={currentProjectPath}
|
||||||
|
locale={locale}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{errorDialog && (
|
{errorDialog && (
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
title={errorDialog.title}
|
title={errorDialog.title}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ interface MenuBarProps {
|
|||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
onToggleDevtools?: () => void;
|
onToggleDevtools?: () => void;
|
||||||
onOpenAbout?: () => void;
|
onOpenAbout?: () => void;
|
||||||
|
onCreatePlugin?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MenuBar({
|
export function MenuBar({
|
||||||
@@ -51,7 +52,8 @@ export function MenuBar({
|
|||||||
onOpenPortManager,
|
onOpenPortManager,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onToggleDevtools,
|
onToggleDevtools,
|
||||||
onOpenAbout
|
onOpenAbout,
|
||||||
|
onCreatePlugin
|
||||||
}: MenuBarProps) {
|
}: MenuBarProps) {
|
||||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||||
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
|
||||||
@@ -144,6 +146,7 @@ export function MenuBar({
|
|||||||
viewport: 'Viewport',
|
viewport: 'Viewport',
|
||||||
pluginManager: 'Plugin Manager',
|
pluginManager: 'Plugin Manager',
|
||||||
tools: 'Tools',
|
tools: 'Tools',
|
||||||
|
createPlugin: 'Create Plugin',
|
||||||
portManager: 'Port Manager',
|
portManager: 'Port Manager',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
help: 'Help',
|
help: 'Help',
|
||||||
@@ -177,6 +180,7 @@ export function MenuBar({
|
|||||||
viewport: '视口',
|
viewport: '视口',
|
||||||
pluginManager: '插件管理器',
|
pluginManager: '插件管理器',
|
||||||
tools: '工具',
|
tools: '工具',
|
||||||
|
createPlugin: '创建插件',
|
||||||
portManager: '端口管理器',
|
portManager: '端口管理器',
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
help: '帮助',
|
help: '帮助',
|
||||||
@@ -226,6 +230,8 @@ export function MenuBar({
|
|||||||
{ label: t('devtools'), onClick: onToggleDevtools }
|
{ label: t('devtools'), onClick: onToggleDevtools }
|
||||||
],
|
],
|
||||||
tools: [
|
tools: [
|
||||||
|
{ label: t('createPlugin'), onClick: onCreatePlugin },
|
||||||
|
{ separator: true },
|
||||||
{ label: t('portManager'), onClick: onOpenPortManager },
|
{ label: t('portManager'), onClick: onOpenPortManager },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{ label: t('settings'), onClick: onOpenSettings }
|
{ label: t('settings'), onClick: onOpenSettings }
|
||||||
|
|||||||
213
packages/editor-app/src/components/PluginGeneratorWindow.tsx
Normal file
213
packages/editor-app/src/components/PluginGeneratorWindow.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { X, FolderOpen } from 'lucide-react';
|
||||||
|
import { TauriAPI } from '../api/tauri';
|
||||||
|
import '../styles/PluginGeneratorWindow.css';
|
||||||
|
|
||||||
|
interface PluginGeneratorWindowProps {
|
||||||
|
onClose: () => void;
|
||||||
|
projectPath: string | null;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginGeneratorWindow({ onClose, projectPath, locale }: PluginGeneratorWindowProps) {
|
||||||
|
const [pluginName, setPluginName] = useState('');
|
||||||
|
const [pluginVersion, setPluginVersion] = useState('1.0.0');
|
||||||
|
const [outputPath, setOutputPath] = useState(projectPath ? `${projectPath}/plugins` : '');
|
||||||
|
const [includeExample, setIncludeExample] = useState(true);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const t = (key: string) => {
|
||||||
|
const translations: Record<string, Record<string, string>> = {
|
||||||
|
zh: {
|
||||||
|
title: '创建插件',
|
||||||
|
pluginName: '插件名称',
|
||||||
|
pluginNamePlaceholder: '例如: my-game-plugin',
|
||||||
|
pluginVersion: '插件版本',
|
||||||
|
outputPath: '输出路径',
|
||||||
|
selectPath: '选择路径',
|
||||||
|
includeExample: '包含示例节点',
|
||||||
|
generate: '生成插件',
|
||||||
|
cancel: '取消',
|
||||||
|
generating: '正在生成...',
|
||||||
|
success: '插件创建成功!',
|
||||||
|
errorEmpty: '请输入插件名称',
|
||||||
|
errorInvalidName: '插件名称只能包含字母、数字、连字符和下划线',
|
||||||
|
errorNoPath: '请选择输出路径'
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: 'Create Plugin',
|
||||||
|
pluginName: 'Plugin Name',
|
||||||
|
pluginNamePlaceholder: 'e.g: my-game-plugin',
|
||||||
|
pluginVersion: 'Plugin Version',
|
||||||
|
outputPath: 'Output Path',
|
||||||
|
selectPath: 'Select Path',
|
||||||
|
includeExample: 'Include Example Node',
|
||||||
|
generate: 'Generate Plugin',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
generating: 'Generating...',
|
||||||
|
success: 'Plugin created successfully!',
|
||||||
|
errorEmpty: 'Please enter plugin name',
|
||||||
|
errorInvalidName: 'Plugin name can only contain letters, numbers, hyphens and underscores',
|
||||||
|
errorNoPath: 'Please select output path'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return translations[locale]?.[key] || translations.en?.[key] || key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectPath = async () => {
|
||||||
|
try {
|
||||||
|
const selected = await TauriAPI.openProjectDialog();
|
||||||
|
if (selected) {
|
||||||
|
setOutputPath(selected);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to select path:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatePluginName = (name: string): boolean => {
|
||||||
|
if (!name) {
|
||||||
|
setError(t('errorEmpty'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9-_]+$/.test(name)) {
|
||||||
|
setError(t('errorInvalidName'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!validatePluginName(pluginName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outputPath) {
|
||||||
|
setError(t('errorNoPath'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/@plugin-generator', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
pluginName,
|
||||||
|
pluginVersion,
|
||||||
|
outputPath,
|
||||||
|
includeExample
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to generate plugin');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(t('success'));
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate plugin:', error);
|
||||||
|
setError(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-content plugin-generator-window" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>{t('title')}</h2>
|
||||||
|
<button className="close-btn" onClick={onClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('pluginName')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pluginName}
|
||||||
|
onChange={e => setPluginName(e.target.value)}
|
||||||
|
placeholder={t('pluginNamePlaceholder')}
|
||||||
|
disabled={isGenerating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('pluginVersion')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pluginVersion}
|
||||||
|
onChange={e => setPluginVersion(e.target.value)}
|
||||||
|
disabled={isGenerating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>{t('outputPath')}</label>
|
||||||
|
<div className="path-input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={outputPath}
|
||||||
|
onChange={e => setOutputPath(e.target.value)}
|
||||||
|
disabled={isGenerating}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="select-path-btn"
|
||||||
|
onClick={handleSelectPath}
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
<FolderOpen size={16} />
|
||||||
|
{t('selectPath')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeExample}
|
||||||
|
onChange={e => setIncludeExample(e.target.checked)}
|
||||||
|
disabled={isGenerating}
|
||||||
|
/>
|
||||||
|
<span>{t('includeExample')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? t('generating') : t('generate')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{t('cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
packages/editor-app/src/services/PluginLoader.ts
Normal file
189
packages/editor-app/src/services/PluginLoader.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { EditorPluginManager } from '@esengine/editor-core';
|
||||||
|
import type { IEditorPlugin } from '@esengine/editor-core';
|
||||||
|
import { TauriAPI } from '../api/tauri';
|
||||||
|
|
||||||
|
interface PluginPackageJson {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
main?: string;
|
||||||
|
module?: string;
|
||||||
|
exports?: {
|
||||||
|
'.': {
|
||||||
|
import?: string;
|
||||||
|
require?: string;
|
||||||
|
development?: {
|
||||||
|
types?: string;
|
||||||
|
import?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginLoader {
|
||||||
|
private loadedPluginNames: Set<string> = new Set();
|
||||||
|
|
||||||
|
async loadProjectPlugins(projectPath: string, pluginManager: EditorPluginManager): Promise<void> {
|
||||||
|
const pluginsPath = `${projectPath}/plugins`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const exists = await TauriAPI.pathExists(pluginsPath);
|
||||||
|
if (!exists) {
|
||||||
|
console.log('[PluginLoader] No plugins directory found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await TauriAPI.listDirectory(pluginsPath);
|
||||||
|
const pluginDirs = entries.filter(entry => entry.is_dir && !entry.name.startsWith('.'));
|
||||||
|
console.log('[PluginLoader] Found plugin directories:', pluginDirs.map(d => d.name));
|
||||||
|
|
||||||
|
for (const entry of pluginDirs) {
|
||||||
|
const pluginPath = `${pluginsPath}/${entry.name}`;
|
||||||
|
await this.loadPlugin(pluginPath, entry.name, pluginManager);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PluginLoader] Failed to load project plugins:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPlugin(pluginPath: string, pluginDirName: string, pluginManager: EditorPluginManager): Promise<void> {
|
||||||
|
try {
|
||||||
|
const packageJsonPath = `${pluginPath}/package.json`;
|
||||||
|
const packageJsonExists = await TauriAPI.pathExists(packageJsonPath);
|
||||||
|
|
||||||
|
if (!packageJsonExists) {
|
||||||
|
console.warn(`[PluginLoader] No package.json found in ${pluginPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJsonContent = await TauriAPI.readFileContent(packageJsonPath);
|
||||||
|
const packageJson: PluginPackageJson = JSON.parse(packageJsonContent);
|
||||||
|
|
||||||
|
if (this.loadedPluginNames.has(packageJson.name)) {
|
||||||
|
console.log(`[PluginLoader] Plugin ${packageJson.name} already loaded`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entryPoint = 'src/index.ts';
|
||||||
|
|
||||||
|
if (packageJson.exports?.['.']?.development?.import) {
|
||||||
|
entryPoint = packageJson.exports['.'].development.import;
|
||||||
|
} else if (packageJson.exports?.['.']?.import) {
|
||||||
|
const importPath = packageJson.exports['.'].import;
|
||||||
|
if (importPath.startsWith('src/')) {
|
||||||
|
entryPoint = importPath;
|
||||||
|
} else {
|
||||||
|
const srcPath = importPath.replace('dist/', 'src/').replace('.js', '.ts');
|
||||||
|
const srcExists = await TauriAPI.pathExists(`${pluginPath}/${srcPath}`);
|
||||||
|
entryPoint = srcExists ? srcPath : importPath;
|
||||||
|
}
|
||||||
|
} else if (packageJson.module) {
|
||||||
|
const srcPath = packageJson.module.replace('dist/', 'src/').replace('.js', '.ts');
|
||||||
|
const srcExists = await TauriAPI.pathExists(`${pluginPath}/${srcPath}`);
|
||||||
|
entryPoint = srcExists ? srcPath : packageJson.module;
|
||||||
|
} else if (packageJson.main) {
|
||||||
|
const srcPath = packageJson.main.replace('dist/', 'src/').replace('.js', '.ts');
|
||||||
|
const srcExists = await TauriAPI.pathExists(`${pluginPath}/${srcPath}`);
|
||||||
|
entryPoint = srcExists ? srcPath : packageJson.main;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除开头的 ./
|
||||||
|
entryPoint = entryPoint.replace(/^\.\//, '');
|
||||||
|
|
||||||
|
const moduleUrl = `/@user-project/plugins/${pluginDirName}/${entryPoint}`;
|
||||||
|
|
||||||
|
console.log(`[PluginLoader] Loading plugin from: ${moduleUrl}`);
|
||||||
|
|
||||||
|
const module = await import(/* @vite-ignore */ moduleUrl);
|
||||||
|
console.log(`[PluginLoader] Module loaded successfully`);
|
||||||
|
|
||||||
|
let pluginInstance: IEditorPlugin | null = null;
|
||||||
|
try {
|
||||||
|
pluginInstance = this.findPluginInstance(module);
|
||||||
|
} catch (findError) {
|
||||||
|
console.error(`[PluginLoader] Error finding plugin instance:`, findError);
|
||||||
|
console.error(`[PluginLoader] Module object:`, module);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pluginInstance) {
|
||||||
|
console.error(`[PluginLoader] No plugin instance found in ${packageJson.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await pluginManager.installEditor(pluginInstance);
|
||||||
|
this.loadedPluginNames.add(packageJson.name);
|
||||||
|
|
||||||
|
console.log(`[PluginLoader] Successfully loaded plugin: ${packageJson.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[PluginLoader] Failed to load plugin from ${pluginPath}:`, error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error(`[PluginLoader] Error stack:`, error.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findPluginInstance(module: any): IEditorPlugin | null {
|
||||||
|
console.log('[PluginLoader] Module exports:', Object.keys(module));
|
||||||
|
|
||||||
|
if (module.default && this.isPluginInstance(module.default)) {
|
||||||
|
console.log('[PluginLoader] Found plugin in default export');
|
||||||
|
return module.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(module)) {
|
||||||
|
const value = module[key];
|
||||||
|
if (value && this.isPluginInstance(value)) {
|
||||||
|
console.log(`[PluginLoader] Found plugin in export: ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[PluginLoader] No valid plugin instance found. Exports:', module);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPluginInstance(obj: any): obj is IEditorPlugin {
|
||||||
|
try {
|
||||||
|
if (!obj || typeof obj !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRequiredProperties =
|
||||||
|
typeof obj.name === 'string' &&
|
||||||
|
typeof obj.version === 'string' &&
|
||||||
|
typeof obj.displayName === 'string' &&
|
||||||
|
typeof obj.category === 'string' &&
|
||||||
|
typeof obj.install === 'function' &&
|
||||||
|
typeof obj.uninstall === 'function';
|
||||||
|
|
||||||
|
if (!hasRequiredProperties) {
|
||||||
|
console.log('[PluginLoader] Object is not a valid plugin:', {
|
||||||
|
hasName: typeof obj.name === 'string',
|
||||||
|
hasVersion: typeof obj.version === 'string',
|
||||||
|
hasDisplayName: typeof obj.displayName === 'string',
|
||||||
|
hasCategory: typeof obj.category === 'string',
|
||||||
|
hasInstall: typeof obj.install === 'function',
|
||||||
|
hasUninstall: typeof obj.uninstall === 'function',
|
||||||
|
objectType: typeof obj,
|
||||||
|
objectConstructor: obj?.constructor?.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasRequiredProperties;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PluginLoader] Error in isPluginInstance:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async unloadProjectPlugins(pluginManager: EditorPluginManager): Promise<void> {
|
||||||
|
for (const pluginName of this.loadedPluginNames) {
|
||||||
|
try {
|
||||||
|
await pluginManager.uninstallEditor(pluginName);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[PluginLoader] Failed to unload plugin ${pluginName}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.loadedPluginNames.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
208
packages/editor-app/src/styles/PluginGeneratorWindow.css
Normal file
208
packages/editor-app/src/styles/PluginGeneratorWindow.css
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
.plugin-generator-window {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0;
|
||||||
|
width: 600px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: slideIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .close-btn:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .modal-body {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .form-group label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .form-group input[type="text"] {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .form-group input[type="text"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .form-group input[type="text"]:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .path-input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .path-input-group input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .select-path-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--color-bg-overlay);
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .select-path-btn:hover:not(:disabled) {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .select-path-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .checkbox-group {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .checkbox-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .checkbox-group input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .checkbox-group input[type="checkbox"]:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .checkbox-group span {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .error-message {
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(206, 145, 120, 0.1);
|
||||||
|
border: 1px solid rgba(206, 145, 120, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #CE9178;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .modal-footer {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid var(--color-border-default);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .btn {
|
||||||
|
padding: 10px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .btn-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .btn-secondary {
|
||||||
|
background: var(--color-bg-overlay);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-generator-window .btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { World, Entity, Scene, createLogger, Time, Core } from '@esengine/ecs-framework';
|
import { World, Entity, Scene, createLogger, Time, Core, ComponentRegistry, Component } from '@esengine/ecs-framework';
|
||||||
import {
|
import {
|
||||||
BehaviorTreeNode as BehaviorTreeNodeComponent,
|
BehaviorTreeNode as BehaviorTreeNodeComponent,
|
||||||
BlackboardComponent,
|
BlackboardComponent,
|
||||||
@@ -324,17 +324,19 @@ export class BehaviorTreeExecutor {
|
|||||||
private addNodeComponents(entity: Entity, node: BehaviorTreeNode): void {
|
private addNodeComponents(entity: Entity, node: BehaviorTreeNode): void {
|
||||||
const category = node.template.category;
|
const category = node.template.category;
|
||||||
const data = node.data;
|
const data = node.data;
|
||||||
|
const nodeType = node.template.type;
|
||||||
|
|
||||||
if (category === '根节点' || data.nodeType === 'root') {
|
if (category === '根节点' || data.nodeType === 'root') {
|
||||||
// 根节点使用专门的 RootNode 组件
|
// 根节点使用专门的 RootNode 组件
|
||||||
entity.addComponent(new RootNode());
|
entity.addComponent(new RootNode());
|
||||||
} else if (category === '动作') {
|
} else if (nodeType === NodeType.Action) {
|
||||||
|
// 根据节点类型而不是 category 来判断,这样可以支持自定义 category
|
||||||
this.addActionComponent(entity, node);
|
this.addActionComponent(entity, node);
|
||||||
} else if (category === '条件') {
|
} else if (nodeType === NodeType.Condition) {
|
||||||
this.addConditionComponent(entity, node);
|
this.addConditionComponent(entity, node);
|
||||||
} else if (category === '组合') {
|
} else if (nodeType === NodeType.Composite) {
|
||||||
this.addCompositeComponent(entity, node);
|
this.addCompositeComponent(entity, node);
|
||||||
} else if (category === '装饰器') {
|
} else if (nodeType === NodeType.Decorator) {
|
||||||
this.addDecoratorComponent(entity, node);
|
this.addDecoratorComponent(entity, node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,6 +371,21 @@ export class BehaviorTreeExecutor {
|
|||||||
const action = new ExecuteAction();
|
const action = new ExecuteAction();
|
||||||
action.actionCode = node.data.actionCode ?? 'return TaskStatus.Success;';
|
action.actionCode = node.data.actionCode ?? 'return TaskStatus.Success;';
|
||||||
entity.addComponent(action);
|
entity.addComponent(action);
|
||||||
|
} else {
|
||||||
|
const ComponentClass = node.template.componentClass ||
|
||||||
|
(node.template.className ? ComponentRegistry.getComponentType(node.template.className) : null);
|
||||||
|
|
||||||
|
if (ComponentClass) {
|
||||||
|
try {
|
||||||
|
const component = new (ComponentClass as any)();
|
||||||
|
Object.assign(component, node.data);
|
||||||
|
entity.addComponent(component as Component);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`创建动作组件失败: ${node.template.className}, error: ${error}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`未找到动作组件类: ${node.template.className}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,6 +417,21 @@ export class BehaviorTreeExecutor {
|
|||||||
condition.conditionCode = node.data.conditionCode ?? '';
|
condition.conditionCode = node.data.conditionCode ?? '';
|
||||||
condition.invertResult = node.data.invertResult ?? false;
|
condition.invertResult = node.data.invertResult ?? false;
|
||||||
entity.addComponent(condition);
|
entity.addComponent(condition);
|
||||||
|
} else {
|
||||||
|
const ComponentClass = node.template.componentClass ||
|
||||||
|
(node.template.className ? ComponentRegistry.getComponentType(node.template.className) : null);
|
||||||
|
|
||||||
|
if (ComponentClass) {
|
||||||
|
try {
|
||||||
|
const component = new (ComponentClass as any)();
|
||||||
|
Object.assign(component, node.data);
|
||||||
|
entity.addComponent(component as Component);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`创建条件组件失败: ${node.template.className}, error: ${error}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`未找到条件组件类: ${node.template.className}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react-swc';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { transformSync } from 'esbuild';
|
|
||||||
|
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
@@ -45,89 +44,86 @@ loadEditorPackages();
|
|||||||
|
|
||||||
const userProjectPlugin = () => ({
|
const userProjectPlugin = () => ({
|
||||||
name: 'user-project-middleware',
|
name: 'user-project-middleware',
|
||||||
configureServer(server: any) {
|
resolveId(id: string, importer?: string) {
|
||||||
server.middlewares.use(async (req: any, res: any, next: any) => {
|
if (id.startsWith('/@user-project/')) {
|
||||||
if (req.url?.startsWith('/@user-project/')) {
|
return id;
|
||||||
const urlWithoutQuery = req.url.split('?')[0];
|
}
|
||||||
const relativePath = decodeURIComponent(urlWithoutQuery.substring('/@user-project'.length));
|
|
||||||
|
|
||||||
|
// 处理从 /@user-project/ 模块导入的相对路径
|
||||||
|
if (importer && importer.startsWith('/@user-project/')) {
|
||||||
|
if (id.startsWith('./') || id.startsWith('../')) {
|
||||||
|
const importerDir = path.dirname(importer.substring('/@user-project'.length));
|
||||||
|
let resolvedPath = path.join(importerDir, id);
|
||||||
|
resolvedPath = resolvedPath.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
// 尝试添加扩展名
|
||||||
let projectPath: string | null = null;
|
let projectPath: string | null = null;
|
||||||
for (const [, path] of userProjectPathMap) {
|
for (const [, p] of userProjectPathMap) {
|
||||||
projectPath = path;
|
projectPath = p;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!projectPath) {
|
if (projectPath) {
|
||||||
res.statusCode = 503;
|
const possibleExtensions = ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.js'];
|
||||||
res.end('Project path not set. Please open a project first.');
|
for (const ext of possibleExtensions) {
|
||||||
return;
|
const testPath = path.join(projectPath, resolvedPath + ext);
|
||||||
}
|
if (fs.existsSync(testPath) && !fs.statSync(testPath).isDirectory()) {
|
||||||
|
return '/@user-project' + (resolvedPath + ext).replace(/\\/g, '/');
|
||||||
const filePath = path.join(projectPath, relativePath);
|
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
console.error('[Vite] File not found:', filePath);
|
|
||||||
res.statusCode = 404;
|
|
||||||
res.end(`File not found: ${filePath}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.statSync(filePath).isDirectory()) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.end(`Path is a directory: ${filePath}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let content = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
|
|
||||||
editorPackageMapping.forEach((srcPath, packageName) => {
|
|
||||||
const escapedPackageName = packageName.replace(/\//g, '\\/');
|
|
||||||
const regex = new RegExp(`from\\s+['"]${escapedPackageName}['"]`, 'g');
|
|
||||||
content = content.replace(
|
|
||||||
regex,
|
|
||||||
`from "/@fs/${srcPath.replace(/\\/g, '/')}"`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileDir = path.dirname(filePath);
|
|
||||||
const relativeImportRegex = /from\s+['"](\.\.?\/[^'"]+)['"]/g;
|
|
||||||
content = content.replace(relativeImportRegex, (match, importPath) => {
|
|
||||||
if (importPath.match(/\.(ts|js|tsx|jsx)$/)) {
|
|
||||||
return match;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const possibleExtensions = ['.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.js'];
|
|
||||||
for (const ext of possibleExtensions) {
|
|
||||||
const resolvedPath = path.join(fileDir, importPath + ext);
|
|
||||||
if (fs.existsSync(resolvedPath)) {
|
|
||||||
const normalizedImport = (importPath + ext).replace(/\\/g, '/');
|
|
||||||
return match.replace(importPath, normalizedImport);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return match;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = transformSync(content, {
|
|
||||||
loader: 'ts',
|
|
||||||
format: 'esm',
|
|
||||||
target: 'es2020',
|
|
||||||
sourcemap: 'inline',
|
|
||||||
sourcefile: filePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
res.end(result.code);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[Vite] Failed to transform TypeScript:', err);
|
|
||||||
res.statusCode = 500;
|
|
||||||
res.end(`Failed to compile: ${err.message}`);
|
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
|
return '/@user-project' + resolvedPath;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
load(id: string) {
|
||||||
|
if (id.startsWith('/@user-project/')) {
|
||||||
|
const relativePath = decodeURIComponent(id.substring('/@user-project'.length));
|
||||||
|
|
||||||
|
let projectPath: string | null = null;
|
||||||
|
for (const [, p] of userProjectPathMap) {
|
||||||
|
projectPath = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectPath) {
|
||||||
|
throw new Error('Project path not set. Please open a project first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(projectPath, relativePath);
|
||||||
|
console.log('[Vite] Loading file:', id);
|
||||||
|
console.log('[Vite] Resolved path:', filePath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error(`File not found: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.statSync(filePath).isDirectory()) {
|
||||||
|
throw new Error(`Path is a directory: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
|
||||||
|
editorPackageMapping.forEach((srcPath, packageName) => {
|
||||||
|
const escapedPackageName = packageName.replace(/\//g, '\\/');
|
||||||
|
const regex = new RegExp(`from\\s+['"]${escapedPackageName}['"]`, 'g');
|
||||||
|
content = content.replace(
|
||||||
|
regex,
|
||||||
|
`from "/@fs/${srcPath.replace(/\\/g, '/')}"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 直接返回源码,让 Vite 的转换管道处理
|
||||||
|
// Vite 已经正确配置了 TypeScript 和装饰器的转换
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
configureServer(server: any) {
|
||||||
|
server.middlewares.use(async (req: any, res: any, next: any) => {
|
||||||
|
|
||||||
if (req.url === '/@ecs-framework-shim') {
|
if (req.url === '/@ecs-framework-shim') {
|
||||||
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
||||||
@@ -155,13 +151,221 @@ const userProjectPlugin = () => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.url === '/@plugin-generator') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (chunk: any) => {
|
||||||
|
body += chunk.toString();
|
||||||
|
});
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const { pluginName, pluginVersion, outputPath, includeExample } = JSON.parse(body);
|
||||||
|
|
||||||
|
const pluginPath = path.join(outputPath, pluginName);
|
||||||
|
|
||||||
|
if (fs.existsSync(pluginPath)) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.end(JSON.stringify({ error: 'Plugin directory already exists' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(pluginPath, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(pluginPath, 'src'), { recursive: true });
|
||||||
|
if (includeExample) {
|
||||||
|
fs.mkdirSync(path.join(pluginPath, 'src', 'nodes'), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJson = {
|
||||||
|
name: pluginName,
|
||||||
|
version: pluginVersion,
|
||||||
|
description: `Behavior tree plugin for ${pluginName}`,
|
||||||
|
main: 'dist/index.js',
|
||||||
|
module: 'dist/index.js',
|
||||||
|
types: 'dist/index.d.ts',
|
||||||
|
exports: {
|
||||||
|
'.': {
|
||||||
|
types: './dist/index.d.ts',
|
||||||
|
import: './dist/index.js',
|
||||||
|
development: {
|
||||||
|
types: './src/index.ts',
|
||||||
|
import: './src/index.ts'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scripts: {
|
||||||
|
build: 'tsc',
|
||||||
|
watch: 'tsc --watch'
|
||||||
|
},
|
||||||
|
peerDependencies: {
|
||||||
|
'@esengine/ecs-framework': '^2.2.8',
|
||||||
|
'@esengine/editor-core': '^1.0.0'
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
'@esengine/behavior-tree': '^1.0.0'
|
||||||
|
},
|
||||||
|
devDependencies: {
|
||||||
|
'typescript': '^5.8.3'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(pluginPath, 'package.json'),
|
||||||
|
JSON.stringify(packageJson, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const tsconfig = {
|
||||||
|
compilerOptions: {
|
||||||
|
target: 'ES2020',
|
||||||
|
module: 'ESNext',
|
||||||
|
moduleResolution: 'node',
|
||||||
|
declaration: true,
|
||||||
|
outDir: './dist',
|
||||||
|
strict: true,
|
||||||
|
esModuleInterop: true,
|
||||||
|
skipLibCheck: true,
|
||||||
|
forceConsistentCasingInFileNames: true,
|
||||||
|
experimentalDecorators: true,
|
||||||
|
emitDecoratorMetadata: true
|
||||||
|
},
|
||||||
|
include: ['src/**/*'],
|
||||||
|
exclude: ['node_modules', 'dist']
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(pluginPath, 'tsconfig.json'),
|
||||||
|
JSON.stringify(tsconfig, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const pluginInstanceName = `${pluginName.replace(/-/g, '')}Plugin`;
|
||||||
|
|
||||||
|
const indexTs = includeExample
|
||||||
|
? `import './nodes/ExampleAction';
|
||||||
|
|
||||||
|
export { ${pluginInstanceName} } from './plugin';
|
||||||
|
export * from './nodes/ExampleAction';
|
||||||
|
|
||||||
|
// 默认导出插件实例
|
||||||
|
import { ${pluginInstanceName} as pluginInstance } from './plugin';
|
||||||
|
export default pluginInstance;
|
||||||
|
`
|
||||||
|
: `export { ${pluginInstanceName} } from './plugin';
|
||||||
|
|
||||||
|
// 默认导出插件实例
|
||||||
|
import { ${pluginInstanceName} as pluginInstance } from './plugin';
|
||||||
|
export default pluginInstance;
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(path.join(pluginPath, 'src', 'index.ts'), indexTs);
|
||||||
|
|
||||||
|
const pluginTs = `import type { IEditorPlugin } from '@esengine/editor-core';
|
||||||
|
import { EditorPluginCategory } from '@esengine/editor-core';
|
||||||
|
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
|
||||||
|
import { getRegisteredNodeTemplates } from '@esengine/behavior-tree';
|
||||||
|
import type { NodeTemplate } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
export class ${pluginName.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')}Plugin implements IEditorPlugin {
|
||||||
|
readonly name = '${pluginName}';
|
||||||
|
readonly version = '${pluginVersion}';
|
||||||
|
readonly displayName = '${pluginName}';
|
||||||
|
readonly category = EditorPluginCategory.Tool;
|
||||||
|
readonly description = 'Behavior tree plugin for ${pluginName}';
|
||||||
|
|
||||||
|
async install(core: Core, services: ServiceContainer): Promise<void> {
|
||||||
|
console.log('[${pluginName}] Plugin installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstall(): Promise<void> {
|
||||||
|
console.log('[${pluginName}] Plugin uninstalled');
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeTemplates(): NodeTemplate[] {
|
||||||
|
return getRegisteredNodeTemplates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ${pluginName.replace(/-/g, '')}Plugin = new ${pluginName.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')}Plugin();
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(path.join(pluginPath, 'src', 'plugin.ts'), pluginTs);
|
||||||
|
|
||||||
|
if (includeExample) {
|
||||||
|
const exampleActionTs = `import { Component, Entity, ECSComponent, Serialize } from '@esengine/ecs-framework';
|
||||||
|
import { BehaviorNode, BehaviorProperty, NodeType, TaskStatus, BlackboardComponent } from '@esengine/behavior-tree';
|
||||||
|
|
||||||
|
@ECSComponent('ExampleAction')
|
||||||
|
@BehaviorNode({
|
||||||
|
displayName: '示例动作',
|
||||||
|
category: '自定义',
|
||||||
|
type: NodeType.Action,
|
||||||
|
icon: 'Star',
|
||||||
|
description: '这是一个示例动作节点',
|
||||||
|
color: '#FF9800'
|
||||||
|
})
|
||||||
|
export class ExampleAction extends Component {
|
||||||
|
@Serialize()
|
||||||
|
@BehaviorProperty({
|
||||||
|
label: '消息内容',
|
||||||
|
type: 'string',
|
||||||
|
description: '要打印的消息'
|
||||||
|
})
|
||||||
|
message: string = 'Hello from example action!';
|
||||||
|
|
||||||
|
execute(entity: Entity, blackboard?: BlackboardComponent): TaskStatus {
|
||||||
|
console.log(this.message);
|
||||||
|
return TaskStatus.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(pluginPath, 'src', 'nodes', 'ExampleAction.ts'),
|
||||||
|
exampleActionTs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const readme = `# ${pluginName}
|
||||||
|
|
||||||
|
Behavior tree plugin for ${pluginName}
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
在编辑器中加载此插件:
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
import { ${pluginName.replace(/-/g, '')}Plugin } from '${pluginName}';
|
||||||
|
import { EditorPluginManager } from '@esengine/editor-core';
|
||||||
|
|
||||||
|
// 在编辑器启动时注册插件
|
||||||
|
const pluginManager = Core.services.resolve(EditorPluginManager);
|
||||||
|
await pluginManager.installEditor(${pluginName.replace(/-/g, '')}Plugin);
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(path.join(pluginPath, 'README.md'), readme);
|
||||||
|
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.end(JSON.stringify({ success: true, path: pluginPath }));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Vite] Failed to generate plugin:', err);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), userProjectPlugin()],
|
plugins: [
|
||||||
|
...react({
|
||||||
|
tsDecorators: true,
|
||||||
|
}),
|
||||||
|
userProjectPlugin() as any
|
||||||
|
],
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: {
|
server: {
|
||||||
host: host || false,
|
host: host || false,
|
||||||
|
|||||||
Reference in New Issue
Block a user