diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index f7eb604d..ae01353d 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -82,6 +82,21 @@ export default defineConfig({ { 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: '事件系统 (Event)', link: '/guide/event-system' }, { text: '时间和定时器 (Time)', link: '/guide/time-and-timers' }, diff --git a/docs/guide/behavior-tree/advanced-usage.md b/docs/guide/behavior-tree/advanced-usage.md new file mode 100644 index 00000000..3088e91c --- /dev/null +++ b/docs/guide/behavior-tree/advanced-usage.md @@ -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 = 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)了解所有内置节点 diff --git a/docs/guide/behavior-tree/best-practices.md b/docs/guide/behavior-tree/best-practices.md new file mode 100644 index 00000000..2ec2608c --- /dev/null +++ b/docs/guide/behavior-tree/best-practices.md @@ -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('health'); + const target = bb?.getValue('target'); + const state = bb?.getValue('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)了解更多技巧 diff --git a/docs/guide/behavior-tree/cocos-integration.md b/docs/guide/behavior-tree/cocos-integration.md new file mode 100644 index 00000000..d99b3fe9 --- /dev/null +++ b/docs/guide/behavior-tree/cocos-integration.md @@ -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 diff --git a/docs/guide/behavior-tree/core-concepts.md b/docs/guide/behavior-tree/core-concepts.md new file mode 100644 index 00000000..2e8b8fea --- /dev/null +++ b/docs/guide/behavior-tree/core-concepts.md @@ -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; +} +``` + +### 系统驱动行为 + +行为树系统负责更新所有节点: + +```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)学习设计模式 diff --git a/docs/guide/behavior-tree/custom-actions.md b/docs/guide/behavior-tree/custom-actions.md new file mode 100644 index 00000000..2c440997 --- /dev/null +++ b/docs/guide/behavior-tree/custom-actions.md @@ -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) diff --git a/docs/guide/behavior-tree/editor-guide.md b/docs/guide/behavior-tree/editor-guide.md new file mode 100644 index 00000000..9230262f --- /dev/null +++ b/docs/guide/behavior-tree/editor-guide.md @@ -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)学习如何扩展节点 diff --git a/docs/guide/behavior-tree/editor-workflow.md b/docs/guide/behavior-tree/editor-workflow.md new file mode 100644 index 00000000..3da70a86 --- /dev/null +++ b/docs/guide/behavior-tree/editor-workflow.md @@ -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设计 diff --git a/docs/guide/behavior-tree/getting-started.md b/docs/guide/behavior-tree/getting-started.md new file mode 100644 index 00000000..85d8b674 --- /dev/null +++ b/docs/guide/behavior-tree/getting-started.md @@ -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('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('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('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('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); +``` diff --git a/docs/guide/behavior-tree/index.md b/docs/guide/behavior-tree/index.md new file mode 100644 index 00000000..e77ce1ae --- /dev/null +++ b/docs/guide/behavior-tree/index.md @@ -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) +- 加入社区讨论 diff --git a/docs/guide/behavior-tree/laya-integration.md b/docs/guide/behavior-tree/laya-integration.md new file mode 100644 index 00000000..b9026001 --- /dev/null +++ b/docs/guide/behavior-tree/laya-integration.md @@ -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) diff --git a/docs/guide/editor-plugin-system.md b/docs/guide/editor-plugin-system.md new file mode 100644 index 00000000..e69de29b diff --git a/package-lock.json b/package-lock.json index 639946b8..53b3d27c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7964,6 +7964,242 @@ "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": { "version": "2.8.0", "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" } }, + "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": { "version": "5.2.4", "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": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", @@ -26077,14 +26354,17 @@ "zustand": "^5.0.8" }, "devDependencies": { + "@swc/core": "^1.13.5", "@tauri-apps/cli": "^2.2.0", "@tauri-apps/plugin-updater": "^2.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react-swc": "^4.2.0", "sharp": "^0.34.4", "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": { @@ -26617,16 +26897,28 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@esengine/ecs-framework": "file:../core", "tslib": "^2.8.1" }, "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/node": "^20.19.17", "jest": "^29.7.0", "rimraf": "^5.0.0", + "rollup": "^4.42.0", + "rollup-plugin-dts": "^6.2.1", "ts-jest": "^29.4.0", "typescript": "^5.8.3" + }, + "peerDependencies": { + "@esengine/ecs-framework": "^2.2.8" } }, "packages/math": { diff --git a/packages/behavior-tree/src/Decorators/BehaviorNodeDecorator.ts b/packages/behavior-tree/src/Decorators/BehaviorNodeDecorator.ts index a10e10d0..aaa48689 100644 --- a/packages/behavior-tree/src/Decorators/BehaviorNodeDecorator.ts +++ b/packages/behavior-tree/src/Decorators/BehaviorNodeDecorator.ts @@ -1,5 +1,6 @@ import { NodeTemplate, PropertyDefinition } from '../Serialization/NodeTemplates'; import { NodeType } from '../Types/TaskStatus'; +import { getComponentTypeName } from '@esengine/ecs-framework'; /** * 行为树节点元数据 @@ -80,7 +81,7 @@ export function BehaviorNode(metadata: BehaviorNodeMetadata) { return function (constructor: T) { const metadataWithClassName = { ...metadata, - className: constructor.name + className: getComponentTypeName(constructor as any) }; NodeClassRegistry.registerNodeClass(constructor, metadataWithClassName); return constructor; @@ -129,14 +130,12 @@ export const NodeProperty = BehaviorProperty; */ export function getRegisteredNodeTemplates(): NodeTemplate[] { return NodeClassRegistry.getAllNodeClasses().map(({ metadata, constructor }) => { - // 从类的 __nodeProperties 收集属性定义 const propertyDefs = constructor.__nodeProperties || []; const defaultConfig: any = { nodeType: metadata.type.toLowerCase() }; - // 从类的默认值中提取配置,并补充 defaultValue const instance = new constructor(); const properties: PropertyDefinition[] = propertyDefs.map((prop: PropertyDefinition) => { const defaultValue = instance[prop.name]; @@ -149,7 +148,6 @@ export function getRegisteredNodeTemplates(): NodeTemplate[] { }; }); - // 添加子类型字段 switch (metadata.type) { case NodeType.Composite: defaultConfig.compositeType = metadata.displayName; @@ -173,6 +171,7 @@ export function getRegisteredNodeTemplates(): NodeTemplate[] { description: metadata.description, color: metadata.color, className: metadata.className, + componentClass: constructor, requiresChildren: metadata.requiresChildren, defaultConfig, properties diff --git a/packages/behavior-tree/src/Serialization/BehaviorTreeAssetLoader.ts b/packages/behavior-tree/src/Serialization/BehaviorTreeAssetLoader.ts index 8a6aff14..975d17bb 100644 --- a/packages/behavior-tree/src/Serialization/BehaviorTreeAssetLoader.ts +++ b/packages/behavior-tree/src/Serialization/BehaviorTreeAssetLoader.ts @@ -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 { BehaviorTreeNode } from '../Components/BehaviorTreeNode'; import { BlackboardComponent } from '../Components/BlackboardComponent'; @@ -306,6 +306,19 @@ export class BehaviorTreeAssetLoader { } else if (nameLower.includes('execute') || nameLower.includes('自定义')) { const action = entity.addComponent(new ExecuteAction()); 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 { logger.warn(`未知的动作类型: ${name}`); } @@ -335,6 +348,19 @@ export class BehaviorTreeAssetLoader { const condition = entity.addComponent(new ExecuteCondition()); condition.conditionCode = data.conditionCode ?? ''; 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 { logger.warn(`未知的条件类型: ${name}`); } diff --git a/packages/behavior-tree/src/Serialization/EditorFormatConverter.ts b/packages/behavior-tree/src/Serialization/EditorFormatConverter.ts index adae4c6f..3f153168 100644 --- a/packages/behavior-tree/src/Serialization/EditorFormatConverter.ts +++ b/packages/behavior-tree/src/Serialization/EditorFormatConverter.ts @@ -67,13 +67,11 @@ export class EditorFormatConverter { static toAsset(editorData: EditorFormat, metadata?: Partial): BehaviorTreeAsset { logger.info('开始转换编辑器格式到资产格式'); - // 查找根节点 const rootNode = this.findRootNode(editorData.nodes); if (!rootNode) { throw new Error('未找到根节点'); } - // 转换元数据 const assetMetadata: AssetMetadata = { name: metadata?.name || editorData.metadata?.name || 'Untitled Behavior Tree', description: metadata?.description || editorData.metadata?.description, @@ -82,13 +80,10 @@ export class EditorFormatConverter { modifiedAt: metadata?.modifiedAt || new Date().toISOString() }; - // 转换节点 const nodes = this.convertNodes(editorData.nodes); - // 转换黑板 const blackboard = this.convertBlackboard(editorData.blackboard); - // 转换属性绑定 const propertyBindings = this.convertPropertyBindings( editorData.connections, editorData.nodes, @@ -130,11 +125,13 @@ export class EditorFormatConverter { * 转换单个节点 */ private static convertNode(editorNode: EditorNode): BehaviorTreeNodeData { - // 复制data,去除编辑器特有的字段 const data = { ...editorNode.data }; - // 移除可能存在的UI相关字段 - delete data.nodeType; // 这个信息已经在nodeType字段中 + delete data.nodeType; + + if (editorNode.template.className) { + data.className = editorNode.template.className; + } return { id: editorNode.id, @@ -152,7 +149,6 @@ export class EditorFormatConverter { const variables: BlackboardVariableDefinition[] = []; for (const [name, value] of Object.entries(blackboard)) { - // 推断类型 const type = this.inferBlackboardType(value); variables.push({ @@ -191,7 +187,6 @@ export class EditorFormatConverter { const bindings: PropertyBinding[] = []; const blackboardVarNames = new Set(blackboard.map(v => v.name)); - // 只处理属性类型的连接 const propertyConnections = connections.filter(conn => conn.connectionType === 'property'); for (const conn of propertyConnections) { @@ -205,7 +200,6 @@ export class EditorFormatConverter { let variableName: string | undefined; - // 检查 from 节点是否是黑板变量节点 if (fromNode.data.nodeType === 'blackboard-variable') { variableName = fromNode.data.variableName; } else if (conn.fromProperty) { @@ -241,22 +235,18 @@ export class EditorFormatConverter { static fromAsset(asset: BehaviorTreeAsset): EditorFormat { logger.info('开始转换资产格式到编辑器格式'); - // 转换节点 const nodes = this.convertNodesFromAsset(asset.nodes); - // 转换黑板 const blackboard: Record = {}; for (const variable of asset.blackboard) { blackboard[variable.name] = variable.defaultValue; } - // 转换属性绑定为连接 const connections = this.convertPropertyBindingsToConnections( asset.propertyBindings || [], asset.nodes ); - // 添加节点连接(基于children关系) const nodeConnections = this.buildNodeConnections(asset.nodes); connections.push(...nodeConnections); @@ -287,19 +277,24 @@ export class EditorFormatConverter { */ private static convertNodesFromAsset(assetNodes: BehaviorTreeNodeData[]): EditorNode[] { return assetNodes.map((node, index) => { - // 简单的自动布局:按索引计算位置 const position = { x: 100 + (index % 5) * 250, 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 { id: node.id, - template: { - displayName: node.name, - category: this.inferCategory(node.nodeType), - type: node.nodeType - }, + template, data: { ...node.data }, position, children: node.children @@ -335,10 +330,8 @@ export class EditorFormatConverter { const connections: EditorConnection[] = []; for (const binding of bindings) { - // 需要找到代表这个黑板变量的节点(如果有的话) - // 这里简化处理,在实际使用中可能需要更复杂的逻辑 connections.push({ - from: 'blackboard', // 占位符,实际使用时需要更复杂的处理 + from: 'blackboard', to: binding.nodeId, toProperty: binding.propertyName, connectionType: 'property' diff --git a/packages/behavior-tree/src/Serialization/NodeTemplates.ts b/packages/behavior-tree/src/Serialization/NodeTemplates.ts index a8b05b35..c44e2727 100644 --- a/packages/behavior-tree/src/Serialization/NodeTemplates.ts +++ b/packages/behavior-tree/src/Serialization/NodeTemplates.ts @@ -2,7 +2,7 @@ import { NodeType } from '../Types/TaskStatus'; import { getRegisteredNodeTemplates } from '../Decorators/BehaviorNodeDecorator'; /** - * 节点数据JSON格式(用于编辑器) + * 节点数据JSON格式 */ export interface NodeDataJSON { nodeType: string; @@ -11,12 +11,49 @@ export interface NodeDataJSON { [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 { name: string; - type: 'string' | 'number' | 'boolean' | 'select' | 'blackboard' | 'code' | 'variable' | 'asset'; + type: PropertyType; label: string; description?: string; defaultValue?: any; @@ -25,6 +62,62 @@ export interface PropertyDefinition { max?: number; step?: number; required?: boolean; + + /** + * 自定义渲染配置 + * + * 用于指定编辑器如何渲染此属性 + * + * @example + * ```typescript + * renderConfig: { + * component: 'ColorPicker', // 渲染器组件名称 + * props: { // 传递给组件的属性 + * showAlpha: true, + * presets: ['#FF0000', '#00FF00'] + * } + * } + * ``` + */ + renderConfig?: { + /** 渲染器组件名称或路径 */ + component?: string; + /** 传递给渲染器的属性配置 */ + props?: Record; + /** 渲染器的样式类名 */ + className?: string; + /** 渲染器的内联样式 */ + style?: Record; + /** 其他自定义配置 */ + [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; color?: string; className?: string; + componentClass?: Function; requiresChildren?: boolean; defaultConfig: Partial; properties: PropertyDefinition[]; diff --git a/packages/behavior-tree/src/Systems/LeafExecutionSystem.ts b/packages/behavior-tree/src/Systems/LeafExecutionSystem.ts index edd204b7..d5e0d86a 100644 --- a/packages/behavior-tree/src/Systems/LeafExecutionSystem.ts +++ b/packages/behavior-tree/src/Systems/LeafExecutionSystem.ts @@ -67,7 +67,10 @@ export class LeafExecutionSystem extends EntitySystem { } else if (entity.hasComponent(ExecuteAction)) { status = this.executeCustomAction(entity); } else { - this.outputLog(entity, `动作节点没有找到任何已知的动作组件`, 'warn'); + status = this.executeGenericAction(entity); + if (status === TaskStatus.Failure) { + this.outputLog(entity, `动作节点没有找到任何已知的动作组件`, 'warn'); + } } node.status = status; @@ -298,6 +301,41 @@ export class LeafExecutionSystem extends EntitySystem { 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; + } + /** * 执行条件节点 */ diff --git a/packages/behavior-tree/src/Types/TaskStatus.ts b/packages/behavior-tree/src/Types/TaskStatus.ts index c0fd620f..d2f3b3a2 100644 --- a/packages/behavior-tree/src/Types/TaskStatus.ts +++ b/packages/behavior-tree/src/Types/TaskStatus.ts @@ -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; /** * 复合节点类型 diff --git a/packages/editor-app/.swcrc b/packages/editor-app/.swcrc new file mode 100644 index 00000000..c182d36b --- /dev/null +++ b/packages/editor-app/.swcrc @@ -0,0 +1,18 @@ +{ + "jsc": { + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": true + }, + "transform": { + "legacyDecorator": true, + "decoratorMetadata": true, + "useDefineForClassFields": false, + "react": { + "runtime": "automatic" + } + }, + "target": "es2020" + } +} diff --git a/packages/editor-app/package.json b/packages/editor-app/package.json index c282987d..e4d22676 100644 --- a/packages/editor-app/package.json +++ b/packages/editor-app/package.json @@ -32,13 +32,16 @@ "zustand": "^5.0.8" }, "devDependencies": { + "@swc/core": "^1.13.5", "@tauri-apps/cli": "^2.2.0", "@tauri-apps/plugin-updater": "^2.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react-swc": "^4.2.0", "sharp": "^0.34.4", "typescript": "^5.8.3", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vite-plugin-swc-transform": "^1.1.1" } } diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index 3fd60e28..e683c5f3 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -20,6 +20,7 @@ import { AboutDialog } from './components/AboutDialog'; import { ErrorDialog } from './components/ErrorDialog'; import { ConfirmDialog } from './components/ConfirmDialog'; import { BehaviorTreeWindow } from './components/BehaviorTreeWindow'; +import { PluginGeneratorWindow } from './components/PluginGeneratorWindow'; import { ToastProvider } from './components/Toast'; import { Viewport } from './components/Viewport'; import { MenuBar } from './components/MenuBar'; @@ -27,6 +28,7 @@ import { FlexLayoutDockContainer, FlexDockPanel } from './components/FlexLayoutD import { TauriAPI } from './api/tauri'; import { TauriFileAPI } from './adapters/TauriFileAPI'; import { SettingsService } from './services/SettingsService'; +import { PluginLoader } from './services/PluginLoader'; import { checkForUpdatesOnStartup } from './utils/updater'; import { useLocale } from './hooks/useLocale'; import { en, zh } from './locales'; @@ -45,6 +47,7 @@ Core.services.registerSingleton(GlobalBlackboardService); function App() { const initRef = useRef(false); + const pluginLoaderRef = useRef(new PluginLoader()); const [initialized, setInitialized] = useState(false); const [projectLoaded, setProjectLoaded] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -67,6 +70,7 @@ function App() { const [showAbout, setShowAbout] = useState(false); const [showBehaviorTreeEditor, setShowBehaviorTreeEditor] = useState(false); const [behaviorTreeFilePath, setBehaviorTreeFilePath] = useState(null); + const [showPluginGenerator, setShowPluginGenerator] = useState(false); const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0); const [isRemoteConnected, setIsRemoteConnected] = useState(false); const [isProfilerMode, setIsProfilerMode] = useState(false); @@ -274,6 +278,12 @@ function App() { setCurrentProjectPath(projectPath); setProjectLoaded(true); + + if (pluginManager) { + setLoadingMessage(locale === 'zh' ? '加载项目插件...' : 'Loading project plugins...'); + await pluginLoaderRef.current.loadProjectPlugins(projectPath, pluginManager); + } + setIsLoading(false); } catch (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); setCurrentProjectPath(null); setIsProfilerMode(false); @@ -514,6 +527,10 @@ function App() { setShowAbout(true); }; + const handleCreatePlugin = () => { + setShowPluginGenerator(true); + }; + useEffect(() => { if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) { let corePanels: FlexDockPanel[]; @@ -675,6 +692,7 @@ function App() { onOpenSettings={() => setShowSettings(true)} onToggleDevtools={handleToggleDevtools} onOpenAbout={handleOpenAbout} + onCreatePlugin={handleCreatePlugin} />
+
+ +
+
+ + setPluginName(e.target.value)} + placeholder={t('pluginNamePlaceholder')} + disabled={isGenerating} + /> +
+ +
+ + setPluginVersion(e.target.value)} + disabled={isGenerating} + /> +
+ +
+ +
+ setOutputPath(e.target.value)} + disabled={isGenerating} + /> + +
+
+ +
+ +
+ + {error && ( +
+ {error} +
+ )} +
+ +
+ + +
+ + + ); +} diff --git a/packages/editor-app/src/services/PluginLoader.ts b/packages/editor-app/src/services/PluginLoader.ts new file mode 100644 index 00000000..c338cc78 --- /dev/null +++ b/packages/editor-app/src/services/PluginLoader.ts @@ -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 = new Set(); + + async loadProjectPlugins(projectPath: string, pluginManager: EditorPluginManager): Promise { + 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 { + 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 { + 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(); + } +} diff --git a/packages/editor-app/src/styles/PluginGeneratorWindow.css b/packages/editor-app/src/styles/PluginGeneratorWindow.css new file mode 100644 index 00000000..d75ad399 --- /dev/null +++ b/packages/editor-app/src/styles/PluginGeneratorWindow.css @@ -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); +} diff --git a/packages/editor-app/src/utils/BehaviorTreeExecutor.ts b/packages/editor-app/src/utils/BehaviorTreeExecutor.ts index 8eb5512f..f6a79c7c 100644 --- a/packages/editor-app/src/utils/BehaviorTreeExecutor.ts +++ b/packages/editor-app/src/utils/BehaviorTreeExecutor.ts @@ -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 { BehaviorTreeNode as BehaviorTreeNodeComponent, BlackboardComponent, @@ -324,17 +324,19 @@ export class BehaviorTreeExecutor { private addNodeComponents(entity: Entity, node: BehaviorTreeNode): void { const category = node.template.category; const data = node.data; + const nodeType = node.template.type; if (category === '根节点' || data.nodeType === 'root') { // 根节点使用专门的 RootNode 组件 entity.addComponent(new RootNode()); - } else if (category === '动作') { + } else if (nodeType === NodeType.Action) { + // 根据节点类型而不是 category 来判断,这样可以支持自定义 category this.addActionComponent(entity, node); - } else if (category === '条件') { + } else if (nodeType === NodeType.Condition) { this.addConditionComponent(entity, node); - } else if (category === '组合') { + } else if (nodeType === NodeType.Composite) { this.addCompositeComponent(entity, node); - } else if (category === '装饰器') { + } else if (nodeType === NodeType.Decorator) { this.addDecoratorComponent(entity, node); } } @@ -369,6 +371,21 @@ export class BehaviorTreeExecutor { const action = new ExecuteAction(); action.actionCode = node.data.actionCode ?? 'return TaskStatus.Success;'; 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.invertResult = node.data.invertResult ?? false; 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}`); + } } } diff --git a/packages/editor-app/vite.config.ts b/packages/editor-app/vite.config.ts index bef77d96..7179aa61 100644 --- a/packages/editor-app/vite.config.ts +++ b/packages/editor-app/vite.config.ts @@ -1,8 +1,7 @@ import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; +import react from '@vitejs/plugin-react-swc'; import fs from 'fs'; import path from 'path'; -import { transformSync } from 'esbuild'; const host = process.env.TAURI_DEV_HOST; @@ -45,89 +44,86 @@ loadEditorPackages(); const userProjectPlugin = () => ({ name: 'user-project-middleware', - configureServer(server: any) { - server.middlewares.use(async (req: any, res: any, next: any) => { - if (req.url?.startsWith('/@user-project/')) { - const urlWithoutQuery = req.url.split('?')[0]; - const relativePath = decodeURIComponent(urlWithoutQuery.substring('/@user-project'.length)); + resolveId(id: string, importer?: string) { + if (id.startsWith('/@user-project/')) { + return id; + } + // 处理从 /@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; - for (const [, path] of userProjectPathMap) { - projectPath = path; + for (const [, p] of userProjectPathMap) { + projectPath = p; break; } - if (!projectPath) { - res.statusCode = 503; - res.end('Project path not set. Please open a project first.'); - return; - } - - 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; + if (projectPath) { + const possibleExtensions = ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.js']; + for (const ext of possibleExtensions) { + const testPath = path.join(projectPath, resolvedPath + ext); + if (fs.existsSync(testPath) && !fs.statSync(testPath).isDirectory()) { + return '/@user-project' + (resolvedPath + ext).replace(/\\/g, '/'); } - - 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') { res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); @@ -155,13 +151,221 @@ const userProjectPlugin = () => ({ 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 { + console.log('[${pluginName}] Plugin installed'); + } + + async uninstall(): Promise { + 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(); }); } }); export default defineConfig({ - plugins: [react(), userProjectPlugin()], + plugins: [ + ...react({ + tsDecorators: true, + }), + userProjectPlugin() as any + ], clearScreen: false, server: { host: host || false,