From 577f1e429afcbfe95802228e4c676ca62f9505a9 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 17 Jun 2025 18:28:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=A1=8C=E4=B8=BA=E6=A0=91?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitmodules | 3 + .../extensions/cocos-ecs-extension/i18n/zh.js | 16 +- .../cocos-ecs-extension/package.json | 41 + .../cocos-ecs-extension/source/main.ts | 233 ++++- .../behavior-tree/composables/useAppState.ts | 99 ++ .../composables/useBehaviorTreeEditor.ts | 666 +++++++++++++ .../composables/useCodeGeneration.ts | 108 +++ .../composables/useComputedProperties.ts | 127 +++ .../composables/useFileOperations.ts | 68 ++ .../composables/useInstallation.ts | 47 + .../composables/useNodeOperations.ts | 132 +++ .../behavior-tree/data/nodeTemplates.ts | 510 ++++++++++ .../source/panels/behavior-tree/index.ts | 61 ++ .../panels/behavior-tree/types/index.ts | 59 ++ .../behavior-tree/utils/canvasManager.ts | 208 ++++ .../panels/behavior-tree/utils/canvasUtils.ts | 109 +++ .../behavior-tree/utils/codeGenerator.ts | 184 ++++ .../behavior-tree/utils/connectionManager.ts | 334 +++++++ .../behavior-tree/utils/connectionUtils.ts | 219 +++++ .../panels/behavior-tree/utils/dragUtils.ts | 229 +++++ .../behavior-tree/utils/installUtils.ts | 110 +++ .../panels/behavior-tree/utils/nodeUtils.ts | 160 ++++ .../static/style/behavior-tree/index.css | 902 ++++++++++++++++++ .../behavior-tree/BehaviorTreeEditor.html | 407 ++++++++ .../template/behavior-tree/TreeNodeItem.html | 16 + .../static/template/behavior-tree/index.html | 3 + extensions/cocos/cocos-ecs/package-lock.json | 9 + extensions/cocos/cocos-ecs/package.json | 1 + thirdparty/BehaviourTree-ai | 1 + 29 files changed, 5060 insertions(+), 2 deletions(-) create mode 100644 .gitmodules create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useAppState.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useBehaviorTreeEditor.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useCodeGeneration.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useComputedProperties.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useFileOperations.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useInstallation.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useNodeOperations.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/data/nodeTemplates.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/index.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/types/index.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/canvasManager.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/canvasUtils.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/codeGenerator.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/connectionManager.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/connectionUtils.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/dragUtils.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/installUtils.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/nodeUtils.ts create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/style/behavior-tree/index.css create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/template/behavior-tree/BehaviorTreeEditor.html create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/template/behavior-tree/TreeNodeItem.html create mode 100644 extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/template/behavior-tree/index.html create mode 160000 thirdparty/BehaviourTree-ai diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..8ab1ab6b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "thirdparty/BehaviourTree-ai"] + path = thirdparty/BehaviourTree-ai + url = https://github.com/esengine/BehaviourTree-ai.git diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/i18n/zh.js b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/i18n/zh.js index f7c2c61b..77aefbef 100644 --- a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/i18n/zh.js +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/i18n/zh.js @@ -1 +1,15 @@ -"use strict";module.exports={open_panel:"默认面板",send_to_panel:"发送消息给面板",description:"专业的ECS框架开发助手:一键安装@esengine/ecs-framework,智能代码生成器快速创建组件和系统,项目模板生成,实时状态检测和版本管理。提供欢迎面板、调试面板和代码生成器,让Cocos Creator的ECS开发更高效便捷。"}; \ No newline at end of file +"use strict"; + +module.exports = { + // 插件描述 + description: "专业的ECS框架开发助手:一键安装@esengine/ecs-framework,智能代码生成器快速创建组件和系统,项目模板生成,实时状态检测和版本管理。提供欢迎面板、调试面板、代码生成器和行为树AI组件库,让Cocos Creator的ECS开发更高效便捷。", + + // 面板相关 + open_panel: "默认面板", + send_to_panel: "发送消息给面板", + + // 菜单相关 + menu: { + panel: "面板" + } +}; \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/package.json b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/package.json index 52b960c7..7de3cc51 100644 --- a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/package.json +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/package.json @@ -56,6 +56,17 @@ "width": 900, "height": 700 } + }, + "behavior-tree": { + "title": "ECS Framework - 行为树AI组件库", + "type": "dockable", + "main": "dist/panels/behavior-tree/index.js", + "size": { + "min-width": 700, + "min-height": 600, + "width": 1000, + "height": 800 + } } }, "contributions": { @@ -78,6 +89,11 @@ "label": "代码生成器", "message": "open-generator" }, + { + "path": "i18n:menu.panel/ECS Framework", + "label": "行为树AI组件库", + "message": "open-behavior-tree" + }, { "path": "i18n:menu.develop/ECS Framework", "label": "ECS 开发工具", @@ -134,6 +150,31 @@ "methods": [ "open-generator" ] + }, + "open-behavior-tree": { + "methods": [ + "open-behavior-tree" + ] + }, + "install-behavior-tree": { + "methods": [ + "install-behavior-tree" + ] + }, + "update-behavior-tree": { + "methods": [ + "update-behavior-tree" + ] + }, + "check-behavior-tree-installed": { + "methods": [ + "check-behavior-tree-installed" + ] + }, + "open-behavior-tree-docs": { + "methods": [ + "open-behavior-tree-docs" + ] } } } diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/main.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/main.ts index 000386a3..541dd971 100644 --- a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/main.ts +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/main.ts @@ -1,8 +1,9 @@ // @ts-ignore import packageJSON from '../package.json'; -import { exec } from 'child_process'; +import { exec, spawn } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; +import * as fsExtra from 'fs-extra'; import { readFileSync, outputFile } from 'fs-extra'; import { join } from 'path'; import { TemplateGenerator } from './TemplateGenerator'; @@ -300,6 +301,236 @@ export const methods: { [key: string]: (...any: any) => any } = { }); } }, + + /** + * 打开行为树AI组件库面板 + */ + 'open-behavior-tree'() { + console.log('Opening Behavior Tree AI panel...'); + try { + Editor.Panel.open(packageJSON.name + '.behavior-tree'); + console.log('Behavior Tree panel opened successfully'); + } catch (error) { + console.error('Failed to open behavior tree panel:', error); + Editor.Dialog.error('打开行为树面板失败', { + detail: `无法打开行为树AI组件库面板:\n\n${error}\n\n请尝试重启Cocos Creator编辑器。`, + }); + } + }, + + /** + * 安装行为树AI系统 + */ + async 'install-behavior-tree'() { + console.log('Installing Behavior Tree AI system...'); + const projectPath = Editor.Project.path; + + try { + // 检查项目路径是否有效 + if (!projectPath || !fs.existsSync(projectPath)) { + throw new Error('无效的项目路径'); + } + + const packageJsonPath = path.join(projectPath, 'package.json'); + + // 检查package.json是否存在 + if (!fs.existsSync(packageJsonPath)) { + throw new Error('项目根目录未找到package.json文件'); + } + + console.log('Installing @esengine/ai package...'); + + // 执行npm安装 + await new Promise((resolve, reject) => { + const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const npmProcess = spawn(cmd, ['install', '@esengine/ai'], { + cwd: projectPath, + stdio: 'pipe', + shell: true + }); + + let stdout = ''; + let stderr = ''; + + npmProcess.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + npmProcess.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + npmProcess.on('close', (code) => { + if (code === 0) { + console.log('NPM install completed successfully'); + console.log('STDOUT:', stdout); + resolve(); + } else { + console.error('NPM install failed with code:', code); + console.error('STDERR:', stderr); + reject(new Error(`NPM安装失败 (退出码: ${code})\n\n${stderr || stdout}`)); + } + }); + + npmProcess.on('error', (error) => { + console.error('NPM process error:', error); + reject(new Error(`NPM进程错误: ${error.message}`)); + }); + }); + + // 复制行为树相关文件到项目中 + const sourceDir = path.join(__dirname, '../../../thirdparty/BehaviourTree-ai'); + const targetDir = path.join(projectPath, 'assets/scripts/AI'); + + if (fs.existsSync(sourceDir)) { + console.log('Copying behavior tree files...'); + await fsExtra.ensureDir(targetDir); + + // 创建示例文件 + const exampleCode = `import { Scene, Entity, Component } from '@esengine/ecs-framework'; +import { BehaviorTreeSystem, BehaviorTreeFactory, TaskStatus } from '@esengine/ai/ecs-integration'; + +/** + * 示例AI组件 + */ +export class AIExampleComponent extends Component { + // 在场景中添加行为树系统 + static setupBehaviorTreeSystem(scene: Scene) { + const behaviorTreeSystem = new BehaviorTreeSystem(); + scene.addEntityProcessor(behaviorTreeSystem); + return behaviorTreeSystem; + } + + // 为实体添加简单AI行为 + static addSimpleAI(entity: Entity) { + BehaviorTreeFactory.addBehaviorTreeToEntity( + entity, + (builder) => builder + .selector() + .action((entity) => { + console.log("AI正在巡逻..."); + return TaskStatus.Success; + }) + .action((entity) => { + console.log("AI正在警戒..."); + return TaskStatus.Success; + }) + .endComposite(), + { debugMode: true } + ); + } +}`; + + const examplePath = path.join(targetDir, 'AIExample.ts'); + await fsExtra.writeFile(examplePath, exampleCode); + console.log('Example file created successfully'); + } + + console.log('Behavior Tree AI system installed successfully'); + return true; + + } catch (error) { + console.error('Failed to install Behavior Tree AI system:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`行为树AI系统安装失败:\n\n${errorMessage}`); + } + }, + + /** + * 更新行为树AI系统 + */ + async 'update-behavior-tree'() { + console.log('Updating Behavior Tree AI system...'); + const projectPath = Editor.Project.path; + + try { + // 检查是否已安装 + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + throw new Error('项目根目录未找到package.json文件'); + } + + const packageJson = await fsExtra.readJson(packageJsonPath); + const dependencies = packageJson.dependencies || {}; + + if (!dependencies['@esengine/ai']) { + throw new Error('尚未安装行为树AI系统,请先进行安装'); + } + + console.log('Checking for updates...'); + + // 执行npm更新 + await new Promise((resolve, reject) => { + const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const npmProcess = spawn(cmd, ['update', '@esengine/ai'], { + cwd: projectPath, + stdio: 'pipe', + shell: true + }); + + npmProcess.on('close', (code) => { + if (code === 0) { + console.log('Update completed successfully'); + resolve(); + } else { + reject(new Error(`更新失败 (退出码: ${code})`)); + } + }); + + npmProcess.on('error', (error) => { + reject(new Error(`更新进程错误: ${error.message}`)); + }); + }); + + console.log('Behavior Tree AI system updated successfully'); + return true; + + } catch (error) { + console.error('Failed to update Behavior Tree AI system:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`行为树AI系统更新失败:\n\n${errorMessage}`); + } + }, + + /** + * 检查行为树AI系统是否已安装 + */ + async 'check-behavior-tree-installed'() { + const projectPath = Editor.Project.path; + + try { + const packageJsonPath = path.join(projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return false; + } + + const packageJson = await fsExtra.readJson(packageJsonPath); + const dependencies = packageJson.dependencies || {}; + + return !!dependencies['@esengine/ai']; + } catch (error) { + console.error('Failed to check installation status:', error); + return false; + } + }, + + /** + * 打开行为树文档 + */ + 'open-behavior-tree-docs'() { + const url = 'https://github.com/esengine/BehaviourTree-ai/blob/master/ecs-integration/README.md'; + + try { + const { shell } = require('electron'); + shell.openExternal(url); + console.log('Behavior Tree documentation opened successfully'); + } catch (error) { + console.error('Failed to open documentation:', error); + Editor.Dialog.info('打开文档', { + detail: `请手动访问以下链接查看行为树文档:\n\n${url}`, + }); + } + }, }; /** diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useAppState.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useAppState.ts new file mode 100644 index 00000000..450e7512 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useAppState.ts @@ -0,0 +1,99 @@ +import { ref } from 'vue'; +import { TreeNode, DragState, Connection } from '../types'; +import { nodeTemplates } from '../data/nodeTemplates'; + +/** + * 应用状态管理 + */ +export function useAppState() { + // 安装状态 + const checkingStatus = ref(true); + const isInstalled = ref(false); + const version = ref(null); + const isInstalling = ref(false); + + // 编辑器状态 + const nodeTemplates_ = ref(nodeTemplates); + const treeNodes = ref([]); + const selectedNodeId = ref(null); + const nodeSearchText = ref(''); + + // 画布状态 + const canvasWidth = ref(800); + const canvasHeight = ref(600); + const zoomLevel = ref(1); + const panX = ref(0); + const panY = ref(0); + + const dragState = ref({ + isDraggingCanvas: false, + isDraggingNode: false, + isConnecting: false, + dragStartX: 0, + dragStartY: 0, + dragNodeId: null, + dragNodeStartX: 0, + dragNodeStartY: 0, + connectionStart: null, + connectionEnd: { x: 0, y: 0 } + }); + + // 连接状态 + const connections = ref([]); + const tempConnection = ref({ path: '' }); + + // UI状态 + const showExportModal = ref(false); + const exportFormat = ref('typescript'); + + // 工具函数 + const getNodeByIdLocal = (id: string): TreeNode | undefined => { + return treeNodes.value.find(node => node.id === id); + }; + + const selectNode = (nodeId: string) => { + selectedNodeId.value = nodeId; + }; + + const newBehaviorTree = () => { + treeNodes.value = []; + selectedNodeId.value = null; + connections.value = []; + tempConnection.value.path = ''; + }; + + return { + // 安装状态 + checkingStatus, + isInstalled, + version, + isInstalling, + + // 编辑器状态 + nodeTemplates: nodeTemplates_, + treeNodes, + selectedNodeId, + nodeSearchText, + + // 画布状态 + canvasWidth, + canvasHeight, + zoomLevel, + panX, + panY, + dragState, + + // 连接状态 + connections, + tempConnection, + + // UI状态 + showExportModal, + exportFormat, + + // 工具函数 + getNodeByIdLocal, + selectNode, + newBehaviorTree + }; +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useBehaviorTreeEditor.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useBehaviorTreeEditor.ts new file mode 100644 index 00000000..90e7c4ce --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useBehaviorTreeEditor.ts @@ -0,0 +1,666 @@ +import { ref, computed, reactive, onMounted, onUnmounted, nextTick } from 'vue'; +import { useAppState } from './useAppState'; +import { useComputedProperties } from './useComputedProperties'; +import { useNodeOperations } from './useNodeOperations'; +import { useCodeGeneration } from './useCodeGeneration'; +import { useInstallation } from './useInstallation'; +import { useFileOperations } from './useFileOperations'; + +/** + * 主要的行为树编辑器组合功能 + */ +export function useBehaviorTreeEditor() { + // Vue Refs for DOM elements + const canvasAreaRef = ref(null); + const svgRef = ref(null); + + // 获取其他组合功能 + const appState = useAppState(); + const computedProps = useComputedProperties( + appState.nodeTemplates, + appState.nodeSearchText, + appState.treeNodes, + appState.selectedNodeId, + appState.checkingStatus, + appState.isInstalling, + appState.isInstalled, + appState.version, + appState.exportFormat, + appState.panX, + appState.panY, + appState.zoomLevel, + appState.getNodeByIdLocal + ); + const nodeOps = useNodeOperations( + appState.treeNodes, + appState.selectedNodeId, + appState.connections, + appState.panX, + appState.panY, + appState.zoomLevel, + appState.getNodeByIdLocal + ); + const codeGen = useCodeGeneration( + appState.treeNodes, + appState.nodeTemplates, + appState.getNodeByIdLocal, + () => computedProps.rootNode() || null + ); + const installation = useInstallation( + appState.checkingStatus, + appState.isInstalled, + appState.version, + appState.isInstalling + ); + const fileOps = useFileOperations( + appState.treeNodes, + appState.selectedNodeId, + appState.connections, + appState.tempConnection, + appState.showExportModal + ); + + // 连线状态管理 - 使用reactive代替复杂的状态管理 + const connectionState = reactive({ + isConnecting: false, + startNodeId: null as string | null, + startPortType: null as 'input' | 'output' | null, + tempPath: '', + currentMousePos: { x: 0, y: 0 }, + hoveredPort: null as { nodeId: string, portType: string } | null + }); + + // 连线方法 + const startConnection = (event: MouseEvent, nodeId: string, portType: 'input' | 'output') => { + event.stopPropagation(); + event.preventDefault(); + + connectionState.isConnecting = true; + connectionState.startNodeId = nodeId; + connectionState.startPortType = portType; + + const startPos = getPortPosition(nodeId, portType); + if (startPos) { + connectionState.currentMousePos = { x: event.clientX, y: event.clientY }; + + (event.target as HTMLElement).setPointerCapture((event as any).pointerId || 1); + + document.addEventListener('pointermove', onConnectionDrag); + document.addEventListener('pointerup', onConnectionEnd); + document.addEventListener('pointercancel', onConnectionEnd); + } else { + cancelConnection(); + } + }; + + const onConnectionDrag = (event: MouseEvent) => { + if (!connectionState.isConnecting || !connectionState.startNodeId) return; + + connectionState.currentMousePos = { x: event.clientX, y: event.clientY }; + + const svgPos = clientToSVGCoordinates(event.clientX, event.clientY); + const startNode = appState.treeNodes.value.find(n => n.id === connectionState.startNodeId); + + if (startNode && svgPos) { + const nodeWidth = 150; + const nodeHeight = 100; + + let startX: number, startY: number; + + if (connectionState.startPortType === 'output') { + startX = startNode.x + nodeWidth / 2; + startY = startNode.y + nodeHeight; + } else { + startX = startNode.x + nodeWidth / 2; + startY = startNode.y; + } + + const targetX = svgPos.x; + const targetY = svgPos.y; + const controlOffset = Math.abs(targetY - startY) * 0.5; + + let path: string; + if (connectionState.startPortType === 'output') { + path = `M ${startX} ${startY} C ${startX} ${startY + controlOffset} ${targetX} ${targetY - controlOffset} ${targetX} ${targetY}`; + } else { + path = `M ${startX} ${startY} C ${startX} ${startY - controlOffset} ${targetX} ${targetY + controlOffset} ${targetX} ${targetY}`; + } + + connectionState.tempPath = path; + } + }; + + const onConnectionEnd = (event: MouseEvent) => { + if (!connectionState.isConnecting) return; + + const targetPort = findTargetPort(event.clientX, event.clientY); + + if (targetPort) { + const { nodeId: targetNodeId, portType: targetPortType } = targetPort; + + if (canConnect(connectionState.startNodeId!, connectionState.startPortType!, targetNodeId, targetPortType)) { + let parentId: string, childId: string; + + if (connectionState.startPortType === 'output') { + parentId = connectionState.startNodeId!; + childId = targetNodeId; + } else { + parentId = targetNodeId; + childId = connectionState.startNodeId!; + } + + createConnection(parentId, childId); + } + } + + cancelConnection(); + }; + + const cancelConnection = () => { + connectionState.isConnecting = false; + connectionState.startNodeId = null; + connectionState.startPortType = null; + connectionState.tempPath = ''; + + // 移除全局事件监听器 + document.removeEventListener('pointermove', onConnectionDrag); + document.removeEventListener('pointerup', onConnectionEnd); + document.removeEventListener('pointercancel', onConnectionEnd); + }; + + // 辅助函数:获取端口在SVG中的坐标(优化计算) + const getPortPosition = (nodeId: string, portType: 'input' | 'output') => { + const node = appState.treeNodes.value.find(n => n.id === nodeId); + if (!node) return null; + + // 使用与连线算法一致的计算方式 + const nodeWidth = 150; + const nodeHeight = 100; + const nodeX = node.x + nodeWidth / 2; // 节点中心X + + let nodeY: number; + if (portType === 'input') { + nodeY = node.y; // 输入端口在顶部 + } else { + nodeY = node.y + nodeHeight; // 输出端口在底部 + } + + return { x: nodeX, y: nodeY }; + }; + + // 辅助函数:将客户端坐标转换为SVG坐标 + const clientToSVGCoordinates = (clientX: number, clientY: number) => { + if (!svgRef.value) return null; + + const svg = svgRef.value as any; // 类型断言解决SVG方法问题 + const point = svg.createSVGPoint(); + point.x = clientX; + point.y = clientY; + + try { + const svgPoint = point.matrixTransform(svg.getScreenCTM()?.inverse()); + // 应用当前的缩放和平移 + return { + x: (svgPoint.x - appState.panX.value) / appState.zoomLevel.value, + y: (svgPoint.y - appState.panY.value) / appState.zoomLevel.value + }; + } catch (e) { + return null; + } + }; + + // 辅助函数:查找目标端口 + const findTargetPort = (clientX: number, clientY: number) => { + if (!canvasAreaRef.value) return null; + + // 方法1: 使用elementFromPoint + const elementAtPoint = document.elementFromPoint(clientX, clientY); + if (elementAtPoint?.classList.contains('port')) { + return getPortInfo(elementAtPoint as HTMLElement); + } + + // 方法2: 遍历所有端口,检查坐标 + const allPorts = canvasAreaRef.value.querySelectorAll('.port'); + for (const port of allPorts) { + const rect = port.getBoundingClientRect(); + const margin = 10; // 增加容错范围 + + if (clientX >= rect.left - margin && clientX <= rect.right + margin && + clientY >= rect.top - margin && clientY <= rect.bottom + margin) { + return getPortInfo(port as HTMLElement); + } + } + + return null; + }; + + // 辅助函数:从端口元素获取端口信息 + const getPortInfo = (portElement: HTMLElement) => { + const nodeElement = portElement.closest('.tree-node'); + if (!nodeElement) return null; + + const nodeId = nodeElement.getAttribute('data-node-id'); + const portType = portElement.classList.contains('port-input') ? 'input' : 'output'; + + return nodeId ? { nodeId, portType } : null; + }; + + // 端口悬停处理 + const onPortHover = (nodeId: string, portType: 'input' | 'output') => { + if (connectionState.isConnecting && connectionState.startNodeId !== nodeId) { + connectionState.hoveredPort = { nodeId, portType }; + + // 检查是否可以连接 + if (canConnect(connectionState.startNodeId!, connectionState.startPortType!, nodeId, portType)) { + // 添加视觉反馈 + const portElement = document.querySelector(`[data-node-id="${nodeId}"] .port-${portType}`); + if (portElement) { + portElement.classList.add('drag-target'); + } + } + } + }; + + const onPortLeave = () => { + if (connectionState.isConnecting) { + connectionState.hoveredPort = null; + + // 移除所有drag-target类 + const allPorts = document.querySelectorAll('.port.drag-target'); + allPorts.forEach(port => port.classList.remove('drag-target')); + } + }; + + // 验证连接目标是否有效 - 排除自己的节点 + const isValidConnectionTarget = (nodeId: string, portType: 'input' | 'output') => { + if (!connectionState.isConnecting || !connectionState.startNodeId || connectionState.startNodeId === nodeId) { + return false; + } + + return canConnect(connectionState.startNodeId, connectionState.startPortType!, nodeId, portType); + }; + + const canConnect = (sourceNodeId: string, sourcePortType: string, targetNodeId: string, targetPortType: string) => { + if (sourceNodeId === targetNodeId) { + return false; + } + + if (sourcePortType === targetPortType) { + return false; + } + + let parentNodeId: string, childNodeId: string; + + if (sourcePortType === 'output') { + parentNodeId = sourceNodeId; + childNodeId = targetNodeId; + } else { + parentNodeId = targetNodeId; + childNodeId = sourceNodeId; + } + + const childNode = appState.treeNodes.value.find((n: any) => n.id === childNodeId); + if (childNode && childNode.parent && childNode.parent !== parentNodeId) { + return false; + } + + const parentNode = appState.treeNodes.value.find((n: any) => n.id === parentNodeId); + if (!parentNode || !parentNode.canHaveChildren) { + return false; + } + + if (!childNode || !childNode.canHaveParent) { + return false; + } + + if (wouldCreateCycle(parentNodeId, childNodeId)) { + return false; + } + + if (isDescendant(childNodeId, parentNodeId)) { + return false; + } + + return true; + }; + + const wouldCreateCycle = (parentId: string, childId: string) => { + return isDescendant(parentId, childId); + }; + + const isDescendant = (ancestorId: string, descendantId: string): boolean => { + const visited = new Set(); + + function checkPath(currentId: string): boolean { + if (currentId === ancestorId) return true; + if (visited.has(currentId)) return false; + + visited.add(currentId); + + const currentNode = appState.treeNodes.value.find((n: any) => n.id === currentId); + if (currentNode?.children) { + for (const childId of currentNode.children) { + if (checkPath(childId)) return true; + } + } + + return false; + } + + return checkPath(descendantId); + }; + + const getAncestors = (nodeId: string): string[] => { + const ancestors: string[] = []; + let currentNode = appState.treeNodes.value.find((n: any) => n.id === nodeId); + + while (currentNode && currentNode.parent) { + ancestors.push(currentNode.parent); + const parentId = currentNode.parent; + currentNode = appState.treeNodes.value.find((n: any) => n.id === parentId); + + if (ancestors.length > 100) break; + } + + return ancestors; + }; + + const getDescendants = (nodeId: string): string[] => { + const descendants: string[] = []; + const visited = new Set(); + + function collectDescendants(currentId: string) { + if (visited.has(currentId)) return; + visited.add(currentId); + + const currentNode = appState.treeNodes.value.find((n: any) => n.id === currentId); + if (currentNode?.children) { + for (const childId of currentNode.children) { + descendants.push(childId); + collectDescendants(childId); + } + } + } + + collectDescendants(nodeId); + return descendants; + }; + + // 创建连接(支持双向连接) + const createConnection = (parentId: string, childId: string) => { + const parentNode = appState.treeNodes.value.find((n: any) => n.id === parentId); + const childNode = appState.treeNodes.value.find((n: any) => n.id === childId); + + if (parentNode && childNode) { + // 移除子节点之前的父节点关系 + if (childNode.parent) { + const oldParent = appState.treeNodes.value.find((n: any) => n.id === childNode.parent); + if (oldParent && oldParent.children) { + oldParent.children = oldParent.children.filter((id: string) => id !== childId); + } + } + + // 移除可能的重复连接 + appState.treeNodes.value.forEach((node: any) => { + if (node.children) { + node.children = node.children.filter((id: string) => !(node.id === parentId && id === childId)); + } + }); + + // 添加新的父子关系 + if (!parentNode.children) { + parentNode.children = []; + } + if (!parentNode.children.includes(childId)) { + parentNode.children.push(childId); + } + + // 设置子节点的父节点引用 + childNode.parent = parentId; + + // 更新连接线 + updateConnections(); + } + }; + + const updateConnections = () => { + appState.connections.value.length = 0; + + appState.treeNodes.value.forEach((node: any) => { + if (node.children) { + node.children.forEach((childId: string) => { + const childNode = appState.treeNodes.value.find((n: any) => n.id === childId); + if (childNode) { + const parentPos = getPortPosition(node.id, 'output'); + const childPos = getPortPosition(childId, 'input'); + + if (parentPos && childPos) { + // 使用与临时连线相同的贝塞尔曲线算法 + const controlOffset = Math.abs(childPos.y - parentPos.y) * 0.5; + const path = `M ${parentPos.x} ${parentPos.y} C ${parentPos.x} ${parentPos.y + controlOffset} ${childPos.x} ${childPos.y - controlOffset} ${childPos.x} ${childPos.y}`; + + appState.connections.value.push({ + id: `${node.id}-${childId}`, + sourceId: node.id, + targetId: childId, + path: path, + active: false + }); + } + } + }); + } + }); + }; + + // 节点拖拽功能(移除防抖,实时更新) + const startNodeDrag = (event: MouseEvent, node: any) => { + // 阻止默认行为 + event.preventDefault(); + event.stopPropagation(); + + // 设置拖拽状态 + appState.dragState.value.isDraggingNode = true; + appState.dragState.value.dragNodeId = node.id; + appState.dragState.value.dragStartX = event.clientX; + appState.dragState.value.dragStartY = event.clientY; + appState.dragState.value.dragNodeStartX = node.x; + appState.dragState.value.dragNodeStartY = node.y; + + // 添加dragging类提升性能 + const nodeElement = event.currentTarget as HTMLElement; + nodeElement.classList.add('dragging'); + + // 添加全局事件监听(移除passive优化,确保实时性) + document.addEventListener('mousemove', onNodeDrag); + document.addEventListener('mouseup', onNodeDragEnd); + }; + + const onNodeDrag = (event: MouseEvent) => { + if (!appState.dragState.value.isDraggingNode || !appState.dragState.value.dragNodeId) return; + + const deltaX = (event.clientX - appState.dragState.value.dragStartX) / appState.zoomLevel.value; + const deltaY = (event.clientY - appState.dragState.value.dragStartY) / appState.zoomLevel.value; + + const node = appState.treeNodes.value.find((n: any) => n.id === appState.dragState.value.dragNodeId); + if (node) { + node.x = appState.dragState.value.dragNodeStartX + deltaX; + node.y = appState.dragState.value.dragNodeStartY + deltaY; + + // 立即更新连接线,无防抖 + updateConnections(); + } + }; + + const onNodeDragEnd = (event: MouseEvent) => { + if (appState.dragState.value.isDraggingNode) { + // 移除dragging类 + const draggingNodes = document.querySelectorAll('.tree-node.dragging'); + draggingNodes.forEach(node => node.classList.remove('dragging')); + + appState.dragState.value.isDraggingNode = false; + appState.dragState.value.dragNodeId = null; + + // 最终更新连接线 + updateConnections(); + + // 移除全局事件监听 + document.removeEventListener('mousemove', onNodeDrag); + document.removeEventListener('mouseup', onNodeDragEnd); + } + }; + + // 画布操作功能 + const onCanvasWheel = (event: WheelEvent) => { + event.preventDefault(); + + const zoomSpeed = 0.1; + const delta = event.deltaY > 0 ? -zoomSpeed : zoomSpeed; + const newZoom = Math.max(0.1, Math.min(3, appState.zoomLevel.value + delta)); + + appState.zoomLevel.value = newZoom; + }; + + const onCanvasMouseDown = (event: MouseEvent) => { + // 只在空白区域开始画布拖拽 + if (event.target === event.currentTarget) { + appState.dragState.value.isDraggingCanvas = true; + appState.dragState.value.dragStartX = event.clientX; + appState.dragState.value.dragStartY = event.clientY; + + document.addEventListener('mousemove', onCanvasMouseMove); + document.addEventListener('mouseup', onCanvasMouseUp); + } + }; + + const onCanvasMouseMove = (event: MouseEvent) => { + if (appState.dragState.value.isDraggingCanvas) { + const deltaX = event.clientX - appState.dragState.value.dragStartX; + const deltaY = event.clientY - appState.dragState.value.dragStartY; + + appState.panX.value += deltaX; + appState.panY.value += deltaY; + + appState.dragState.value.dragStartX = event.clientX; + appState.dragState.value.dragStartY = event.clientY; + } + }; + + const onCanvasMouseUp = (event: MouseEvent) => { + if (appState.dragState.value.isDraggingCanvas) { + appState.dragState.value.isDraggingCanvas = false; + + document.removeEventListener('mousemove', onCanvasMouseMove); + document.removeEventListener('mouseup', onCanvasMouseUp); + } + }; + + // 缩放控制 + const zoomIn = () => { + appState.zoomLevel.value = Math.min(3, appState.zoomLevel.value + 0.1); + }; + + const zoomOut = () => { + appState.zoomLevel.value = Math.max(0.1, appState.zoomLevel.value - 0.1); + }; + + const resetZoom = () => { + appState.zoomLevel.value = 1; + }; + + const centerView = () => { + appState.panX.value = 0; + appState.panY.value = 0; + }; + + // 安装处理 + const handleInstall = () => { + // 这里应该调用installation中的安装方法 + }; + + // 生命周期管理 + onMounted(() => { + // 初始化连接线 + nextTick(() => { + updateConnections(); + }); + }); + + onUnmounted(() => { + // 清理事件监听器 + cancelConnection(); + document.removeEventListener('mousemove', onNodeDrag); + document.removeEventListener('mouseup', onNodeDragEnd); + document.removeEventListener('mousemove', onCanvasMouseMove); + document.removeEventListener('mouseup', onCanvasMouseUp); + }); + + // 解构出所有需要的方法,避免命名冲突 + const { + filteredCompositeNodes, + filteredDecoratorNodes, + filteredActionNodes, + filteredConditionNodes, + filteredECSNodes, + selectedNode, + rootNode, + installStatusClass, + installStatusText, + validationResult, + exportedCode, + gridStyle + } = computedProps; + + return { + // DOM refs + canvasAreaRef, + svgRef, + + // 状态 + ...appState, + connectionState, + + // 计算属性 - 显式导出,避免命名冲突 + filteredCompositeNodes, + filteredDecoratorNodes, + filteredActionNodes, + filteredConditionNodes, + filteredECSNodes, + selectedNode, + rootNode, + installStatusClass, + installStatusText, + validationResult, + exportedCode, + gridStyle, + + // 连线方法 + startConnection, + cancelConnection, + updateConnections, + onPortHover, + onPortLeave, + isValidConnectionTarget, + + // 节点拖拽 + startNodeDrag, + + // 画布操作 + onCanvasWheel, + onCanvasMouseDown, + onCanvasMouseMove, + onCanvasMouseUp, + + // 缩放控制 + zoomIn, + zoomOut, + resetZoom, + centerView, + + // 其他功能方法 + ...nodeOps, + ...codeGen, + ...installation, + ...fileOps, + }; +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useCodeGeneration.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useCodeGeneration.ts new file mode 100644 index 00000000..f727e5b5 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useCodeGeneration.ts @@ -0,0 +1,108 @@ +import { Ref } from 'vue'; +import { TreeNode } from '../types'; +import { NodeTemplate } from '../data/nodeTemplates'; + +/** + * 代码生成管理 + */ +export function useCodeGeneration( + treeNodes: Ref, + nodeTemplates: Ref, + getNodeByIdLocal: (id: string) => TreeNode | undefined, + rootNode: () => TreeNode | null +) { + + // TypeScript代码生成 + const generateTypeScriptCode = (): string => { + const imports = getRequiredImports(); + const root = rootNode(); + + if (!root) { + return '// 请先添加根节点'; + } + + const importsCode = imports.map(imp => `import { ${imp} } from '@esengine/ai';`).join('\n'); + const treeCode = generateNodeCode(root); + + return `${importsCode} + +// 自动生成的行为树代码 +export function createBehaviorTree() { + return ${treeCode}; +}`; + }; + + const getRequiredImports = (): string[] => { + const imports = new Set(); + + treeNodes.value.forEach(node => { + const template = nodeTemplates.value.find(t => t.className === node.type || t.type === node.type); + if (template?.className) { + imports.add(template.className); + } + }); + + return Array.from(imports); + }; + + const generateNodeCode = (node: TreeNode, indent: number = 0): string => { + const spaces = ' '.repeat(indent); + const template = nodeTemplates.value.find(t => t.className === node.type); + + if (!template) { + return `${spaces}// 未知节点类型: ${node.type}`; + } + + let code = `${spaces}new ${template.className}(`; + + // 构造函数参数 + const params: string[] = []; + + // 处理属性 + if (node.properties && Object.keys(node.properties).length > 0) { + const propsCode: string[] = []; + + Object.entries(node.properties).forEach(([key, prop]) => { + if (prop.type === 'code' && prop.value) { + propsCode.push(`${key}: ${prop.value}`); + } else if (prop.type === 'string' && prop.value !== undefined) { + propsCode.push(`${key}: "${prop.value}"`); + } else if (prop.type === 'number' && prop.value !== undefined) { + propsCode.push(`${key}: ${prop.value}`); + } else if (prop.type === 'boolean' && prop.value !== undefined) { + propsCode.push(`${key}: ${prop.value}`); + } else if (prop.type === 'select' && prop.value !== undefined) { + propsCode.push(`${key}: "${prop.value}"`); + } + }); + + if (propsCode.length > 0) { + params.push(`{\n${spaces} ${propsCode.join(',\n' + spaces + ' ')}\n${spaces}}`); + } + } + + code += params.join(', '); + + // 子节点 + if (node.children && node.children.length > 0) { + const children = node.children + .map(childId => getNodeByIdLocal(childId)) + .filter(Boolean) + .map(child => generateNodeCode(child!, indent + 1)); + + if (children.length > 0) { + if (params.length > 0) code += ', '; + code += '[\n' + children.join(',\n') + '\n' + spaces + ']'; + } + } + + code += ')'; + return code; + }; + + return { + generateTypeScriptCode, + generateNodeCode, + getRequiredImports + }; +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useComputedProperties.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useComputedProperties.ts new file mode 100644 index 00000000..33fc2bf0 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useComputedProperties.ts @@ -0,0 +1,127 @@ +import { Ref } from 'vue'; +import { TreeNode } from '../types'; +import { NodeTemplate } from '../data/nodeTemplates'; +import { getRootNode } from '../utils/nodeUtils'; +import { getInstallStatusText, getInstallStatusClass } from '../utils/installUtils'; +import { generateCode } from '../utils/codeGenerator'; +import { getGridStyle } from '../utils/canvasUtils'; + +/** + * 计算属性管理 + */ +export function useComputedProperties( + nodeTemplates: Ref, + nodeSearchText: Ref, + treeNodes: Ref, + selectedNodeId: Ref, + checkingStatus: Ref, + isInstalling: Ref, + isInstalled: Ref, + version: Ref, + exportFormat: Ref, + panX: Ref, + panY: Ref, + zoomLevel: Ref, + getNodeByIdLocal: (id: string) => TreeNode | undefined +) { + // 过滤节点 + const filteredCompositeNodes = () => { + return nodeTemplates.value.filter(node => + node.category === 'composite' && + node.name.toLowerCase().includes(nodeSearchText.value.toLowerCase()) + ); + }; + + const filteredDecoratorNodes = () => { + return nodeTemplates.value.filter(node => + node.category === 'decorator' && + node.name.toLowerCase().includes(nodeSearchText.value.toLowerCase()) + ); + }; + + const filteredActionNodes = () => { + return nodeTemplates.value.filter(node => + node.category === 'action' && + node.name.toLowerCase().includes(nodeSearchText.value.toLowerCase()) + ); + }; + + const filteredConditionNodes = () => { + return nodeTemplates.value.filter(node => + node.category === 'condition' && + node.name.toLowerCase().includes(nodeSearchText.value.toLowerCase()) + ); + }; + + const filteredECSNodes = () => { + return nodeTemplates.value.filter(node => + node.category === 'ecs' && + node.name.toLowerCase().includes(nodeSearchText.value.toLowerCase()) + ); + }; + + // 选中的节点 + const selectedNode = () => { + return selectedNodeId.value ? getNodeByIdLocal(selectedNodeId.value) : null; + }; + + // 根节点 + const rootNode = () => { + return getRootNode(treeNodes.value); + }; + + // 安装状态 + const installStatusClass = () => { + return getInstallStatusClass(isInstalling.value, isInstalled.value); + }; + + const installStatusText = () => { + return getInstallStatusText( + checkingStatus.value, + isInstalling.value, + isInstalled.value, + version.value + ); + }; + + // 验证结果 + const validationResult = () => { + if (treeNodes.value.length === 0) { + return { isValid: false, message: '行为树为空' }; + } + const root = rootNode(); + if (!root) { + return { isValid: false, message: '缺少根节点' }; + } + return { isValid: true, message: '行为树结构有效' }; + }; + + // 导出代码 + const exportedCode = () => { + try { + return generateCode(treeNodes.value, exportFormat.value); + } catch (error) { + return `// 代码生成失败: ${error}`; + } + }; + + // 网格样式 + const gridStyle = () => { + return getGridStyle(panX.value, panY.value, zoomLevel.value); + }; + + return { + filteredCompositeNodes, + filteredDecoratorNodes, + filteredActionNodes, + filteredConditionNodes, + filteredECSNodes, + selectedNode, + rootNode, + installStatusClass, + installStatusText, + validationResult, + exportedCode, + gridStyle + }; +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useFileOperations.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useFileOperations.ts new file mode 100644 index 00000000..d9ed6f23 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useFileOperations.ts @@ -0,0 +1,68 @@ +import { Ref } from 'vue'; +import { TreeNode, Connection } from '../types'; + +/** + * 文件操作管理 + */ +export function useFileOperations( + treeNodes: Ref, + selectedNodeId: Ref, + connections: Ref, + tempConnection: Ref<{ path: string }>, + showExportModal: Ref +) { + + // 工具栏操作 + const newBehaviorTree = () => { + treeNodes.value = []; + selectedNodeId.value = null; + connections.value = []; + tempConnection.value.path = ''; + }; + + const saveBehaviorTree = () => { + // TODO: 实现保存功能 + console.log('保存行为树'); + }; + + const loadBehaviorTree = () => { + // TODO: 实现加载功能 + console.log('加载行为树'); + }; + + const exportCode = () => { + showExportModal.value = true; + }; + + const copyToClipboard = () => { + // TODO: 实现复制到剪贴板功能 + console.log('复制到剪贴板'); + }; + + const saveToFile = () => { + // TODO: 实现保存到文件功能 + console.log('保存到文件'); + }; + + // 验证相关 + const autoLayout = () => { + // TODO: 实现自动布局功能 + console.log('自动布局'); + }; + + const validateTree = () => { + // TODO: 实现树验证功能 + console.log('验证树结构'); + }; + + return { + newBehaviorTree, + saveBehaviorTree, + loadBehaviorTree, + exportCode, + copyToClipboard, + saveToFile, + autoLayout, + validateTree + }; +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useInstallation.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useInstallation.ts new file mode 100644 index 00000000..8c37b39c --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useInstallation.ts @@ -0,0 +1,47 @@ +import { Ref } from 'vue'; +import { checkBehaviorTreeInstalled, installBehaviorTreeAI } from '../utils/installUtils'; + +/** + * 安装管理 + */ +export function useInstallation( + checkingStatus: Ref, + isInstalled: Ref, + version: Ref, + isInstalling: Ref +) { + + // 检查安装状态 + const checkInstallStatus = async () => { + checkingStatus.value = true; + try { + const result = await checkBehaviorTreeInstalled(Editor.Project.path); + isInstalled.value = result.installed; + version.value = result.version; + } catch (error) { + console.error('检查安装状态失败:', error); + isInstalled.value = false; + version.value = null; + } finally { + checkingStatus.value = false; + } + }; + + // 处理安装 + const handleInstall = async () => { + isInstalling.value = true; + try { + await installBehaviorTreeAI(Editor.Project.path); + await checkInstallStatus(); + } catch (error) { + console.error('安装失败:', error); + } finally { + isInstalling.value = false; + } + }; + + return { + checkInstallStatus, + handleInstall + }; +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useNodeOperations.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useNodeOperations.ts new file mode 100644 index 00000000..ae8a8684 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/composables/useNodeOperations.ts @@ -0,0 +1,132 @@ +import { Ref } from 'vue'; +import { TreeNode, Connection } from '../types'; +import { NodeTemplate } from '../data/nodeTemplates'; +import { createNodeFromTemplate } from '../utils/nodeUtils'; +import { getCanvasCoordinates } from '../utils/canvasUtils'; + +/** + * 节点操作管理 + */ +export function useNodeOperations( + treeNodes: Ref, + selectedNodeId: Ref, + connections: Ref, + panX: Ref, + panY: Ref, + zoomLevel: Ref, + getNodeByIdLocal: (id: string) => TreeNode | undefined +) { + + // 获取相对于画布的坐标(用于节点拖放等操作) + const getCanvasCoords = (event: MouseEvent, canvasElement: HTMLElement | null) => { + return getCanvasCoordinates(event, canvasElement, panX.value, panY.value, zoomLevel.value); + }; + + // 拖拽事件处理 + const onNodeDragStart = (event: DragEvent, template: NodeTemplate) => { + if (event.dataTransfer) { + event.dataTransfer.setData('application/json', JSON.stringify(template)); + event.dataTransfer.effectAllowed = 'copy'; + } + }; + + const onCanvasDragOver = (event: DragEvent) => { + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'copy'; + } + }; + + const onCanvasDrop = (event: DragEvent) => { + event.preventDefault(); + + const templateData = event.dataTransfer?.getData('application/json'); + if (!templateData) return; + + try { + const template: NodeTemplate = JSON.parse(templateData); + const canvasElement = event.currentTarget as HTMLElement; + const { x, y } = getCanvasCoords(event, canvasElement); + + const newNode = createNodeFromTemplate(template, x, y); + treeNodes.value.push(newNode); + selectedNodeId.value = newNode.id; + + } catch (error) { + console.error('节点创建失败:', error); + } + }; + + // 节点删除(递归删除子节点) + const deleteNode = (nodeId: string) => { + const deleteRecursive = (id: string) => { + const node = getNodeByIdLocal(id); + if (!node) return; + + // 递归删除子节点 + node.children.forEach(childId => deleteRecursive(childId)); + + // 从父节点的children中移除 + if (node.parent) { + const parent = getNodeByIdLocal(node.parent); + if (parent) { + const index = parent.children.indexOf(id); + if (index > -1) { + parent.children.splice(index, 1); + } + } + } + + // 移除连接 + connections.value = connections.value.filter(conn => + conn.sourceId !== id && conn.targetId !== id + ); + + // 从树中移除节点 + const nodeIndex = treeNodes.value.findIndex(n => n.id === id); + if (nodeIndex > -1) { + treeNodes.value.splice(nodeIndex, 1); + } + }; + + deleteRecursive(nodeId); + + if (selectedNodeId.value === nodeId) { + selectedNodeId.value = null; + } + }; + + // 节点属性更新 + const updateNodeProperty = (path: string, value: any) => { + const node = selectedNodeId.value ? getNodeByIdLocal(selectedNodeId.value) : null; + if (!node) return; + + // 确保 properties 对象存在 + if (!node.properties) { + node.properties = {}; + } + + const keys = path.split('.'); + let target: any = node.properties; + + // 导航到目标对象,如果中间对象不存在则创建 + for (let i = 0; i < keys.length - 1; i++) { + if (!target[keys[i]] || typeof target[keys[i]] !== 'object') { + target[keys[i]] = {}; + } + target = target[keys[i]]; + } + + // 设置最终值 + target[keys[keys.length - 1]] = value; + }; + + return { + getCanvasCoords, + onNodeDragStart, + onCanvasDragOver, + onCanvasDrop, + deleteNode, + updateNodeProperty + }; +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/data/nodeTemplates.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/data/nodeTemplates.ts new file mode 100644 index 00000000..0d309499 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/data/nodeTemplates.ts @@ -0,0 +1,510 @@ +/** + * 节点属性定义接口 + */ +export interface PropertyDefinition { + name: string; + type: 'string' | 'number' | 'boolean' | 'select' | 'code'; + value: any; + description?: string; + options?: string[]; // 用于select类型 + required?: boolean; +} + +/** + * 节点模板接口 + */ +export interface NodeTemplate { + type: string; + name: string; + icon: string; + category: 'composite' | 'decorator' | 'action' | 'condition' | 'ecs'; + description: string; + canHaveChildren: boolean; + canHaveParent: boolean; + properties?: Record; + className?: string; // 对应的实际类名 + namespace?: string; // 命名空间 +} + +/** + * 基于项目实际行为树系统的节点模板定义 + */ +export const nodeTemplates: NodeTemplate[] = [ + // 复合节点 (Composites) + { + type: 'sequence', + name: '序列器', + icon: '→', + category: 'composite', + description: '按顺序执行子节点,任一失败则整体失败', + canHaveChildren: true, + canHaveParent: true, + className: 'Sequence', + namespace: 'behaviourTree/composites', + properties: { + abortType: { + name: '中止类型', + type: 'select', + value: 'None', + options: ['None', 'LowerPriority', 'Self', 'Both'], + description: '决定节点在何种情况下会被中止', + required: false + } + } + }, + { + type: 'selector', + name: '选择器', + icon: '?', + category: 'composite', + description: '按顺序执行子节点,任一成功则整体成功', + canHaveChildren: true, + canHaveParent: true, + className: 'Selector', + namespace: 'behaviourTree/composites', + properties: { + abortType: { + name: '中止类型', + type: 'select', + value: 'None', + options: ['None', 'LowerPriority', 'Self', 'Both'], + description: '决定节点在何种情况下会被中止', + required: false + } + } + }, + { + type: 'parallel', + name: '并行器', + icon: '||', + category: 'composite', + description: '并行执行所有子节点', + canHaveChildren: true, + canHaveParent: true, + className: 'Parallel', + namespace: 'behaviourTree/composites' + }, + { + type: 'parallel-selector', + name: '并行选择器', + icon: '⫸', + category: 'composite', + description: '并行执行子节点,任一成功则成功', + canHaveChildren: true, + canHaveParent: true, + className: 'ParallelSelector', + namespace: 'behaviourTree/composites' + }, + { + type: 'random-selector', + name: '随机选择器', + icon: '🎲?', + category: 'composite', + description: '随机顺序执行子节点,任一成功则成功', + canHaveChildren: true, + canHaveParent: true, + className: 'RandomSelector', + namespace: 'behaviourTree/composites' + }, + { + type: 'random-sequence', + name: '随机序列器', + icon: '🎲→', + category: 'composite', + description: '随机顺序执行子节点,任一失败则失败', + canHaveChildren: true, + canHaveParent: true, + className: 'RandomSequence', + namespace: 'behaviourTree/composites' + }, + + // 装饰器节点 (Decorators) + { + type: 'repeater', + name: '重复器', + icon: '🔄', + category: 'decorator', + description: '重复执行子节点指定次数或无限次', + canHaveChildren: true, + canHaveParent: true, + className: 'Repeater', + namespace: 'behaviourTree/decorators', + properties: { + repeatCount: { + name: '重复次数', + type: 'number', + value: -1, + description: '重复执行次数,-1表示无限重复', + required: true + }, + repeatForever: { + name: '无限重复', + type: 'boolean', + value: true, + description: '是否无限重复执行', + required: false + } + } + }, + { + type: 'inverter', + name: '反转器', + icon: '⚡', + category: 'decorator', + description: '反转子节点的执行结果', + canHaveChildren: true, + canHaveParent: true, + className: 'Inverter', + namespace: 'behaviourTree/decorators' + }, + { + type: 'always-succeed', + name: '总是成功', + icon: '✅', + category: 'decorator', + description: '无论子节点结果如何都返回成功', + canHaveChildren: true, + canHaveParent: true, + className: 'AlwaysSucceed', + namespace: 'behaviourTree/decorators' + }, + { + type: 'always-fail', + name: '总是失败', + icon: '❌', + category: 'decorator', + description: '无论子节点结果如何都返回失败', + canHaveChildren: true, + canHaveParent: true, + className: 'AlwaysFail', + namespace: 'behaviourTree/decorators' + }, + { + type: 'until-success', + name: '直到成功', + icon: '🔁✅', + category: 'decorator', + description: '重复执行子节点直到成功', + canHaveChildren: true, + canHaveParent: true, + className: 'UntilSuccess', + namespace: 'behaviourTree/decorators' + }, + { + type: 'until-fail', + name: '直到失败', + icon: '🔁❌', + category: 'decorator', + description: '重复执行子节点直到失败', + canHaveChildren: true, + canHaveParent: true, + className: 'UntilFail', + namespace: 'behaviourTree/decorators' + }, + { + type: 'conditional-decorator', + name: '条件装饰器', + icon: '🔀', + category: 'decorator', + description: '基于条件执行子节点', + canHaveChildren: true, + canHaveParent: true, + className: 'ConditionalDecorator', + namespace: 'behaviourTree/decorators', + properties: { + conditionCode: { + name: '条件代码', + type: 'code', + value: '(context) => true', + description: '条件判断函数代码', + required: true + } + } + }, + + // 动作节点 (Actions) + { + type: 'execute-action', + name: '执行动作', + icon: '⚡', + category: 'action', + description: '执行自定义代码逻辑', + canHaveChildren: false, + canHaveParent: true, + className: 'ExecuteAction', + namespace: 'behaviourTree/actions', + properties: { + actionCode: { + name: '动作代码', + type: 'code', + value: '(context) => {\n // 在这里编写动作逻辑\n return TaskStatus.Success;\n}', + description: '要执行的动作函数代码', + required: true + }, + actionName: { + name: '动作名称', + type: 'string', + value: '', + description: '用于调试的动作名称', + required: false + } + } + }, + { + type: 'wait-action', + name: '等待动作', + icon: '⏰', + category: 'action', + description: '等待指定时间后完成', + canHaveChildren: false, + canHaveParent: true, + className: 'WaitAction', + namespace: 'behaviourTree/actions', + properties: { + waitTime: { + name: '等待时间', + type: 'number', + value: 1.0, + description: '等待时间(秒)', + required: true + }, + randomVariance: { + name: '随机变化', + type: 'number', + value: 0.0, + description: '时间的随机变化量', + required: false + } + } + }, + { + type: 'log-action', + name: '日志动作', + icon: '📝', + category: 'action', + description: '输出日志信息', + canHaveChildren: false, + canHaveParent: true, + className: 'LogAction', + namespace: 'behaviourTree/actions', + properties: { + message: { + name: '日志消息', + type: 'string', + value: 'Hello from behavior tree!', + description: '要输出的日志消息', + required: true + }, + logLevel: { + name: '日志级别', + type: 'select', + value: 'info', + options: ['debug', 'info', 'warn', 'error'], + description: '日志输出级别', + required: false + } + } + }, + { + type: 'behavior-tree-reference', + name: '行为树引用', + icon: '🌳', + category: 'action', + description: '运行另一个行为树', + canHaveChildren: false, + canHaveParent: true, + className: 'BehaviorTreeReference', + namespace: 'behaviourTree/actions', + properties: { + treeName: { + name: '树名称', + type: 'string', + value: '', + description: '要引用的行为树名称', + required: true + } + } + }, + + // 条件节点 (基础条件) + { + type: 'execute-conditional', + name: '执行条件', + icon: '❓', + category: 'condition', + description: '执行自定义条件判断', + canHaveChildren: false, + canHaveParent: true, + className: 'ExecuteActionConditional', + namespace: 'behaviourTree/conditionals', + properties: { + conditionCode: { + name: '条件代码', + type: 'code', + value: '(context) => {\n // 在这里编写条件判断逻辑\n return TaskStatus.Success; // 或 TaskStatus.Failure\n}', + description: '条件判断函数代码', + required: true + } + } + }, + + // ECS专用节点 + { + type: 'has-component', + name: '检查组件', + icon: '🔍', + category: 'ecs', + description: '检查实体是否包含指定组件', + canHaveChildren: false, + canHaveParent: true, + className: 'HasComponentCondition', + namespace: 'ecs-integration/behaviors', + properties: { + componentType: { + name: '组件类型', + type: 'string', + value: 'Component', + description: '要检查的组件类型名称', + required: true + } + } + }, + { + type: 'add-component', + name: '添加组件', + icon: '➕', + category: 'ecs', + description: '为实体添加组件', + canHaveChildren: false, + canHaveParent: true, + className: 'AddComponentAction', + namespace: 'ecs-integration/behaviors', + properties: { + componentType: { + name: '组件类型', + type: 'string', + value: 'Component', + description: '要添加的组件类型名称', + required: true + } + } + }, + { + type: 'remove-component', + name: '移除组件', + icon: '➖', + category: 'ecs', + description: '从实体移除组件', + canHaveChildren: false, + canHaveParent: true, + className: 'RemoveComponentAction', + namespace: 'ecs-integration/behaviors', + properties: { + componentType: { + name: '组件类型', + type: 'string', + value: 'Component', + description: '要移除的组件类型名称', + required: true + } + } + }, + { + type: 'modify-component', + name: '修改组件', + icon: '✏️', + category: 'ecs', + description: '修改实体组件的属性', + canHaveChildren: false, + canHaveParent: true, + className: 'ModifyComponentAction', + namespace: 'ecs-integration/behaviors', + properties: { + componentType: { + name: '组件类型', + type: 'string', + value: 'Component', + description: '要修改的组件类型名称', + required: true + }, + modifierCode: { + name: '修改代码', + type: 'code', + value: '(component) => {\n // 在这里修改组件属性\n // component.someProperty = newValue;\n}', + description: '组件修改函数代码', + required: true + } + } + }, + { + type: 'has-tag', + name: '检查标签', + icon: '🏷️', + category: 'ecs', + description: '检查实体是否具有指定标签', + canHaveChildren: false, + canHaveParent: true, + className: 'HasTagCondition', + namespace: 'ecs-integration/behaviors', + properties: { + tag: { + name: '标签值', + type: 'number', + value: 0, + description: '要检查的标签值', + required: true + } + } + }, + { + type: 'is-active', + name: '检查激活状态', + icon: '🔋', + category: 'ecs', + description: '检查实体是否处于激活状态', + canHaveChildren: false, + canHaveParent: true, + className: 'IsActiveCondition', + namespace: 'ecs-integration/behaviors', + properties: { + checkHierarchy: { + name: '检查层级', + type: 'boolean', + value: true, + description: '是否检查层级激活状态', + required: false + } + } + }, + { + type: 'wait-time', + name: 'ECS等待', + icon: '⏱️', + category: 'ecs', + description: 'ECS优化的等待动作', + canHaveChildren: false, + canHaveParent: true, + className: 'WaitTimeAction', + namespace: 'ecs-integration/behaviors', + properties: { + waitTime: { + name: '等待时间', + type: 'number', + value: 1.0, + description: '等待时间(秒)', + required: true + } + } + }, + { + type: 'destroy-entity', + name: '销毁实体', + icon: '💥', + category: 'ecs', + description: '销毁当前实体', + canHaveChildren: false, + canHaveParent: true, + className: 'DestroyEntityAction', + namespace: 'ecs-integration/behaviors' + } +]; \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/index.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/index.ts new file mode 100644 index 00000000..8f832954 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/index.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'fs-extra'; +import { join } from 'path'; +import { createApp, App, defineComponent } from 'vue'; +import { useBehaviorTreeEditor } from './composables/useBehaviorTreeEditor'; + +const panelDataMap = new WeakMap(); + +module.exports = Editor.Panel.define({ + listeners: { + show() { }, + hide() { }, + }, + + template: readFileSync(join(__dirname, '../../../static/template/behavior-tree/index.html'), 'utf-8'), + style: readFileSync(join(__dirname, '../../../static/style/behavior-tree/index.css'), 'utf-8'), + + $: { + app: '#behavior-tree-app', + }, + + methods: { + sendToMain(message: string, ...args: any[]) { + Editor.Message.send('cocos-ecs-extension', message, ...args); + } + }, + + ready() { + if (this.$.app) { + const app = createApp({}); + app.config.compilerOptions.isCustomElement = (tag) => tag.startsWith('ui-'); + + // 树节点组件 + app.component('tree-node-item', defineComponent({ + props: ['node', 'level', 'getNodeByIdLocal'], + emits: ['node-select'], + template: readFileSync(join(__dirname, '../../../static/template/behavior-tree/TreeNodeItem.html'), 'utf-8') + })); + + // 行为树编辑器组件 + app.component('BehaviorTreeEditor', defineComponent({ + setup() { + const editor = useBehaviorTreeEditor(); + return editor; + }, + template: readFileSync(join(__dirname, '../../../static/template/behavior-tree/BehaviorTreeEditor.html'), 'utf-8') + })); + + app.mount(this.$.app); + panelDataMap.set(this, app); + } + }, + + beforeClose() { }, + + close() { + const app = panelDataMap.get(this); + if (app) { + app.unmount(); + } + }, +}); \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/types/index.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/types/index.ts new file mode 100644 index 00000000..9ba1b2de --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/types/index.ts @@ -0,0 +1,59 @@ +import { PropertyDefinition } from '../data/nodeTemplates'; + +export interface TreeNode { + id: string; + type: string; + name: string; + icon: string; + description: string; + x: number; + y: number; + children: string[]; + parent?: string; + properties?: Record; + canHaveChildren: boolean; + canHaveParent: boolean; + hasError?: boolean; +} + +export interface Connection { + id: string; + sourceId: string; + targetId: string; + path: string; + active: boolean; +} + +export interface DragState { + isDraggingCanvas: boolean; + isDraggingNode: boolean; + isConnecting: boolean; + dragStartX: number; + dragStartY: number; + dragNodeId: string | null; + dragNodeStartX: number; + dragNodeStartY: number; + connectionStart: { nodeId: string; portType: string } | null; + connectionEnd: { x: number; y: number }; +} + +export interface InstallStatus { + installed: boolean; + version: string | null; + packageExists: boolean; +} + +export interface ValidationResult { + isValid: boolean; + message: string; +} + +export interface ConnectionPort { + nodeId: string; + portType: string; +} + +export interface CanvasCoordinates { + x: number; + y: number; +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/canvasManager.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/canvasManager.ts new file mode 100644 index 00000000..9d3439c2 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/canvasManager.ts @@ -0,0 +1,208 @@ +import { Ref } from 'vue'; +import { TreeNode, DragState } from '../types'; +import { getCanvasCoordinates, constrainZoom } from './canvasUtils'; + +export interface CanvasManager { + onCanvasMouseDown: (event: MouseEvent) => void; + onCanvasMouseMove: (event: MouseEvent) => void; + onCanvasMouseUp: (event: MouseEvent) => void; + onCanvasWheel: (event: WheelEvent) => void; + zoomIn: () => void; + zoomOut: () => void; + resetZoom: () => void; + centerView: () => void; + startNodeDrag: (event: MouseEvent, node: TreeNode) => void; +} + +export function createCanvasManager( + canvasWidth: Ref, + canvasHeight: Ref, + zoomLevel: Ref, + panX: Ref, + panY: Ref, + dragState: Ref, + treeNodes: Ref, + getNodeByIdLocal: (id: string) => TreeNode | undefined, + selectNode: (nodeId: string) => void, + updateConnectionsThrottled: () => void, + connectionManager: { updateTempConnection: (nodeId: string, portType: string, targetX: number, targetY: number) => void }, + findCanvasElement: () => HTMLElement | null, + getSVGInternalCoords: (event: MouseEvent, canvasElement: HTMLElement | null) => { x: number, y: number } +): CanvasManager { + + const UPDATE_THROTTLE = 16; // 60fps + let animationFrameId: number | null = null; + let lastUpdateTime = 0; + + const onCanvasMouseDown = (event: MouseEvent) => { + if (event.button !== 0) return; // 只处理左键 + + dragState.value.isDraggingCanvas = true; + dragState.value.dragStartX = event.clientX; + dragState.value.dragStartY = event.clientY; + }; + + const onCanvasMouseMove = (event: MouseEvent) => { + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + } + + animationFrameId = requestAnimationFrame(() => { + const currentTime = performance.now(); + if (currentTime - lastUpdateTime < UPDATE_THROTTLE) { + return; + } + lastUpdateTime = currentTime; + + if (dragState.value.isDraggingCanvas) { + const deltaX = event.clientX - dragState.value.dragStartX; + const deltaY = event.clientY - dragState.value.dragStartY; + + panX.value += deltaX; + panY.value += deltaY; + + dragState.value.dragStartX = event.clientX; + dragState.value.dragStartY = event.clientY; + } else if (dragState.value.isDraggingNode && dragState.value.dragNodeId) { + const node = getNodeByIdLocal(dragState.value.dragNodeId); + if (node) { + const deltaX = (event.clientX - dragState.value.dragStartX) / zoomLevel.value; + const deltaY = (event.clientY - dragState.value.dragStartY) / zoomLevel.value; + + node.x = dragState.value.dragNodeStartX + deltaX; + node.y = dragState.value.dragNodeStartY + deltaY; + + updateConnectionsThrottled(); + } + } else if (dragState.value.isConnecting && dragState.value.connectionStart) { + let canvasElement = event.currentTarget as HTMLElement | null; + if (!canvasElement) { + canvasElement = findCanvasElement(); + } + + if (canvasElement) { + const { x, y } = getSVGInternalCoords(event, canvasElement); + + dragState.value.connectionEnd.x = x; + dragState.value.connectionEnd.y = y; + + connectionManager.updateTempConnection( + dragState.value.connectionStart.nodeId, + dragState.value.connectionStart.portType, + x, + y + ); + } + } + }); + }; + + const onCanvasMouseUp = (event: MouseEvent) => { + if (dragState.value.isDraggingCanvas) { + dragState.value.isDraggingCanvas = false; + } else if (dragState.value.isDraggingNode) { + // 恢复过渡效果 + if (dragState.value.dragNodeId) { + const nodeElement = document.querySelector(`[data-node-id="${dragState.value.dragNodeId}"]`) as HTMLElement; + if (nodeElement) { + nodeElement.style.transition = ''; + } + } + + dragState.value.isDraggingNode = false; + dragState.value.dragNodeId = null; + } + + // 清理动画帧 + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + }; + + const onCanvasWheel = (event: WheelEvent) => { + event.preventDefault(); + if (event.deltaY < 0) { + zoomIn(); + } else { + zoomOut(); + } + }; + + const zoomIn = () => { + zoomLevel.value = constrainZoom(zoomLevel.value * 1.2); + }; + + const zoomOut = () => { + zoomLevel.value = constrainZoom(zoomLevel.value / 1.2); + }; + + const resetZoom = () => { + zoomLevel.value = 1; + panX.value = 0; + panY.value = 0; + }; + + const centerView = () => { + if (treeNodes.value.length === 0) return; + + // 计算所有节点的边界 + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + treeNodes.value.forEach(node => { + minX = Math.min(minX, node.x); + minY = Math.min(minY, node.y); + maxX = Math.max(maxX, node.x + 150); + maxY = Math.max(maxY, node.y + 100); + }); + + // 计算中心点 + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + // 设置平移,使内容居中 + panX.value = canvasWidth.value / 2 - centerX * zoomLevel.value; + panY.value = canvasHeight.value / 2 - centerY * zoomLevel.value; + }; + + const startNodeDrag = (event: MouseEvent, node: TreeNode) => { + // 检查是否点击的是端口或删除按钮 + const target = event.target as HTMLElement; + if (target.classList.contains('port') || + target.classList.contains('node-delete') || + target.closest('.port') || + target.closest('.node-delete')) { + return; // 不启动节点拖拽 + } + + event.stopPropagation(); + + if (event.button !== 0) return; // 只处理左键 + + dragState.value.isDraggingNode = true; + dragState.value.dragNodeId = node.id; + dragState.value.dragStartX = event.clientX; + dragState.value.dragStartY = event.clientY; + dragState.value.dragNodeStartX = node.x; + dragState.value.dragNodeStartY = node.y; + + const nodeElement = document.querySelector(`[data-node-id="${node.id}"]`) as HTMLElement; + if (nodeElement) { + nodeElement.style.transition = 'none'; + } + + selectNode(node.id); + }; + + return { + onCanvasMouseDown, + onCanvasMouseMove, + onCanvasMouseUp, + onCanvasWheel, + zoomIn, + zoomOut, + resetZoom, + centerView, + startNodeDrag + }; +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/canvasUtils.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/canvasUtils.ts new file mode 100644 index 00000000..2dd8cab3 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/canvasUtils.ts @@ -0,0 +1,109 @@ +import { CanvasCoordinates } from '../types'; + +/** + * 获取相对于画布的坐标(考虑缩放和平移) + */ +export function getCanvasCoordinates( + event: MouseEvent, + canvasElement: HTMLElement | null, + panX: number, + panY: number, + zoomLevel: number +): CanvasCoordinates { + if (!canvasElement) { + return { x: 0, y: 0 }; + } + + try { + const rect = canvasElement.getBoundingClientRect(); + const x = (event.clientX - rect.left - panX) / zoomLevel; + const y = (event.clientY - rect.top - panY) / zoomLevel; + return { x, y }; + } catch (error) { + return { x: 0, y: 0 }; + } +} + +/** + * 计算网格样式 + */ +export function getGridStyle(panX: number, panY: number, zoomLevel: number) { + const gridSize = 20 * zoomLevel; + return { + backgroundSize: `${gridSize}px ${gridSize}px`, + backgroundPosition: `${panX % gridSize}px ${panY % gridSize}px` + }; +} + +/** + * 计算视图居中的平移值 + */ +export function calculateCenterView( + nodes: any[], + canvasWidth: number, + canvasHeight: number, + zoomLevel: number +): { panX: number; panY: number } { + if (nodes.length === 0) { + return { panX: 0, panY: 0 }; + } + + // 计算所有节点的边界 + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + nodes.forEach(node => { + minX = Math.min(minX, node.x); + minY = Math.min(minY, node.y); + maxX = Math.max(maxX, node.x + 150); + maxY = Math.max(maxY, node.y + 100); + }); + + // 计算中心点 + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + // 设置平移,使内容居中 + const panX = canvasWidth / 2 - centerX * zoomLevel; + const panY = canvasHeight / 2 - centerY * zoomLevel; + + return { panX, panY }; +} + +/** + * 约束缩放级别 + */ +export function constrainZoom(zoom: number): number { + return Math.max(0.3, Math.min(zoom, 3)); +} + +/** + * 计算缩放后的坐标 + */ +export function transformCoordinate( + x: number, + y: number, + panX: number, + panY: number, + zoomLevel: number +): { x: number; y: number } { + return { + x: x * zoomLevel + panX, + y: y * zoomLevel + panY + }; +} + +/** + * 计算逆向变换的坐标(从屏幕坐标到画布坐标) + */ +export function inverseTransformCoordinate( + screenX: number, + screenY: number, + panX: number, + panY: number, + zoomLevel: number +): { x: number; y: number } { + return { + x: (screenX - panX) / zoomLevel, + y: (screenY - panY) / zoomLevel + }; +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/codeGenerator.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/codeGenerator.ts new file mode 100644 index 00000000..5f3976d3 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/codeGenerator.ts @@ -0,0 +1,184 @@ +import { TreeNode } from '../types'; +import { getNodeById, getRootNode } from './nodeUtils'; +import { nodeTemplates } from '../data/nodeTemplates'; + +/** + * 生成TypeScript代码 + */ +export function generateTypeScriptCode(nodes: TreeNode[]): string { + const imports = getRequiredImports(nodes); + const root = getRootNode(nodes); + + if (!root) { + return '// 请先添加根节点'; + } + + const importsCode = imports.map(imp => `import { ${imp} } from '@esengine/ai';`).join('\n'); + const treeCode = generateNodeCode(root, nodes); + + return `${importsCode} + +// 自动生成的行为树代码 +export function createBehaviorTree() { + return ${treeCode}; +}`; +} + +/** + * 获取需要导入的类 + */ +export function getRequiredImports(nodes: TreeNode[]): string[] { + const imports = new Set(); + + nodes.forEach(node => { + const template = nodeTemplates.find(t => t.className === node.type || t.type === node.type); + if (template?.className) { + imports.add(template.className); + } + }); + + return Array.from(imports); +} + +/** + * 生成单个节点的代码 + */ +export function generateNodeCode(node: TreeNode, allNodes: TreeNode[], indent: number = 0): string { + const spaces = ' '.repeat(indent); + const template = nodeTemplates.find(t => t.className === node.type); + + if (!template) { + return `${spaces}// 未知节点类型: ${node.type}`; + } + + let code = `${spaces}new ${template.className}(`; + + // 构造函数参数 + const params: string[] = []; + + // 处理属性 + if (node.properties && Object.keys(node.properties).length > 0) { + const propsCode: string[] = []; + + Object.entries(node.properties).forEach(([key, prop]) => { + if (prop.type === 'code' && prop.value) { + propsCode.push(`${key}: ${prop.value}`); + } else if (prop.type === 'string' && prop.value !== undefined) { + propsCode.push(`${key}: "${prop.value}"`); + } else if (prop.type === 'number' && prop.value !== undefined) { + propsCode.push(`${key}: ${prop.value}`); + } else if (prop.type === 'boolean' && prop.value !== undefined) { + propsCode.push(`${key}: ${prop.value}`); + } else if (prop.type === 'select' && prop.value !== undefined) { + propsCode.push(`${key}: "${prop.value}"`); + } + }); + + if (propsCode.length > 0) { + params.push(`{\n${spaces} ${propsCode.join(',\n' + spaces + ' ')}\n${spaces}}`); + } + } + + code += params.join(', '); + + // 子节点 + if (node.children && node.children.length > 0) { + const children = node.children + .map(childId => getNodeById(allNodes, childId)) + .filter(Boolean) + .map(child => generateNodeCode(child!, allNodes, indent + 1)); + + if (children.length > 0) { + if (params.length > 0) code += ', '; + code += '[\n' + children.join(',\n') + '\n' + spaces + ']'; + } + } + + code += ')'; + return code; +} + +/** + * 生成JSON代码 + */ +export function generateJSONCode(nodes: TreeNode[]): string { + const root = getRootNode(nodes); + + if (!root) { + return '// 请先添加根节点'; + } + + const treeData = generateNodeJSON(root, nodes); + + return JSON.stringify({ + type: 'BehaviorTree', + version: '1.0', + created: new Date().toISOString(), + root: treeData + }, null, 2); +} + +/** + * 生成单个节点的JSON + */ +export function generateNodeJSON(node: TreeNode, allNodes: TreeNode[]): any { + const nodeData: any = { + id: node.id, + type: node.type, + name: node.name, + description: node.description, + position: { x: node.x, y: node.y } + }; + + // 添加属性 + if (node.properties && Object.keys(node.properties).length > 0) { + nodeData.properties = {}; + Object.entries(node.properties).forEach(([key, prop]) => { + if (prop.value !== undefined) { + nodeData.properties[key] = prop.value; + } + }); + } + + // 添加子节点 + if (node.children && node.children.length > 0) { + nodeData.children = node.children + .map(childId => getNodeById(allNodes, childId)) + .filter(Boolean) + .map(child => generateNodeJSON(child!, allNodes)); + } + + return nodeData; +} + +/** + * 根据导出格式生成代码 + */ +export function generateCode(nodes: TreeNode[], format: string): string { + switch (format) { + case 'typescript': + return generateTypeScriptCode(nodes); + case 'json': + return generateJSONCode(nodes); + default: + return generateTypeScriptCode(nodes); + } +} + +/** + * 验证生成的代码 + */ +export function validateGeneratedCode(code: string, format: string): { isValid: boolean; error?: string } { + try { + if (format === 'json') { + JSON.parse(code); + } + // TypeScript代码的验证可以在这里添加更复杂的逻辑 + return { isValid: true }; + } catch (error) { + return { + isValid: false, + error: error instanceof Error ? error.message : '未知错误' + }; + } +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/connectionManager.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/connectionManager.ts new file mode 100644 index 00000000..9622d08d --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/connectionManager.ts @@ -0,0 +1,334 @@ +import { Ref } from 'vue'; +import { TreeNode, DragState, Connection } from '../types'; + +export interface ConnectionManager { + startConnection: (event: MouseEvent, nodeId: string, portType: string) => void; + updateTempConnection: (nodeId: string, portType: string, targetX: number, targetY: number) => void; + onConnectionDragEnd: (event: MouseEvent) => void; + cancelConnection: () => void; + createConnection: (sourceId: string, targetId: string) => void; + removeConnection: (sourceId: string, targetId: string) => void; + updateConnections: () => void; + canConnect: (source: { nodeId: string, portType: string }, target: { nodeId: string, portType: string }) => boolean; +} + +export function createConnectionManager( + treeNodes: Ref, + connections: Ref, + tempConnection: Ref<{ path: string }>, + dragState: Ref, + findCanvasElement: () => HTMLElement | null, + getSVGInternalCoords: (event: MouseEvent, canvasElement: HTMLElement | null) => { x: number, y: number }, + getNodeByIdLocal: (id: string) => TreeNode | undefined, + getNodeIdFromElement: (element: HTMLElement) => string | null +): ConnectionManager { + + const startConnection = (event: MouseEvent, nodeId: string, portType: string) => { + event.stopPropagation(); + event.preventDefault(); + + const node = getNodeByIdLocal(nodeId); + if (!node) { + return; + } + + dragState.value.isConnecting = true; + dragState.value.connectionStart = { nodeId, portType }; + + // 使用统一的canvas查找方法 + const canvasElement = findCanvasElement(); + + if (canvasElement) { + const { x, y } = getSVGInternalCoords(event, canvasElement); + + // 为了让连线明显可见,使用一个与端口位置明显不同的初始位置 + const node = getNodeByIdLocal(nodeId); + let initialX, initialY; + + if (node) { + if (portType === 'output') { + // 输出端口:向下延伸50像素 + initialX = node.x + 75; // 节点中心 + initialY = node.y + 150; // 节点底部向下50像素 + } else { + // 输入端口:向上延伸50像素 + initialX = node.x + 75; // 节点中心 + initialY = node.y - 50; // 节点顶部向上50像素 + } + } else { + // fallback到鼠标位置 + initialX = x; + initialY = y; + } + + dragState.value.connectionEnd.x = initialX; + dragState.value.connectionEnd.y = initialY; + + updateTempConnection(nodeId, portType, initialX, initialY); + } + }; + + const updateTempConnection = (nodeId: string, portType: string, targetX: number, targetY: number) => { + const node = getNodeByIdLocal(nodeId); + if (!node) { + return; + } + + // 计算端口的准确位置(在节点坐标系中) + const nodeWidth = 150; + const nodeHeight = 100; + const startX = node.x + nodeWidth / 2; + + let startY: number; + if (portType === 'output') { + startY = node.y + nodeHeight; // 输出端口在底部 + } else { + startY = node.y; // 输入端口在顶部 + } + + // targetX, targetY 现在已经是SVG内部坐标系的坐标,可以直接使用 + // 创建贝塞尔曲线路径 + const controlOffset = Math.abs(targetY - startY) * 0.5; + + let path: string; + if (portType === 'output') { + path = `M ${startX} ${startY} C ${startX} ${startY + controlOffset} ${targetX} ${targetY - controlOffset} ${targetX} ${targetY}`; + } else { + path = `M ${startX} ${startY} C ${startX} ${startY - controlOffset} ${targetX} ${targetY + controlOffset} ${targetX} ${targetY}`; + } + + tempConnection.value.path = path; + }; + + const onConnectionDragEnd = (event: MouseEvent) => { + if (!dragState.value.isConnecting || !dragState.value.connectionStart) return; + + console.log('🔗 连线拖拽结束'); + + // 检查是否释放在目标端口上 + const targetElement = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement; + console.log('🎯 鼠标位置的元素:', targetElement); + console.log('📍 鼠标坐标:', event.clientX, event.clientY); + + // 多种方式查找端口 + let targetPort: HTMLElement | null = null; + + // 方法1: 直接检查当前元素 + if (targetElement?.classList.contains('port')) { + targetPort = targetElement; + console.log('✅ 方法1成功:直接是端口元素'); + } + + // 方法2: 向上查找最近的端口 + if (!targetPort) { + targetPort = targetElement?.closest('.port') as HTMLElement; + if (targetPort) { + console.log('✅ 方法2成功:通过closest找到端口'); + } + } + + // 方法3: 查找当前节点下的所有端口,检查鼠标是否在其范围内 + if (!targetPort) { + const nodeElement = targetElement?.closest('.tree-node') as HTMLElement; + if (nodeElement) { + const ports = nodeElement.querySelectorAll('.port'); + console.log('🔍 在节点中找到', ports.length, '个端口'); + + ports.forEach((port, index) => { + const rect = port.getBoundingClientRect(); + console.log(`端口${index}位置:`, rect); + + if (event.clientX >= rect.left && event.clientX <= rect.right && + event.clientY >= rect.top && event.clientY <= rect.bottom) { + targetPort = port as HTMLElement; + console.log('✅ 方法3成功:鼠标在端口范围内'); + } + }); + } + } + + console.log('🎯 最终找到的端口:', targetPort); + + if (targetPort) { + const targetNodeId = getNodeIdFromElement(targetPort); + const targetPortType = targetPort.classList.contains('port-input') ? 'input' : 'output'; + + console.log('📋 目标节点ID:', targetNodeId); + console.log('🔌 端口类型:', targetPortType); + console.log('🔗 源端口信息:', dragState.value.connectionStart); + + if (targetNodeId && targetNodeId !== dragState.value.connectionStart.nodeId) { + const sourcePort = dragState.value.connectionStart; + const targetPortObj = { nodeId: targetNodeId, portType: targetPortType }; + + const canConn = canConnect(sourcePort, targetPortObj); + console.log('🤔 是否可以连接:', canConn); + + if (canConn) { + if (sourcePort.portType === 'output') { + createConnection(sourcePort.nodeId, targetNodeId); + console.log('✅ 创建连接:', sourcePort.nodeId, '->', targetNodeId); + } else { + createConnection(targetNodeId, sourcePort.nodeId); + console.log('✅ 创建连接:', targetNodeId, '->', sourcePort.nodeId); + } + } else { + console.log('❌ 无法连接:不满足连接条件'); + } + } else { + console.log('❌ 无法连接:目标节点无效或是同一节点'); + } + } else { + console.log('❌ 没有找到目标端口'); + } + + // 清理连线状态 + cancelConnection(); + }; + + const cancelConnection = () => { + dragState.value.isConnecting = false; + dragState.value.connectionStart = null; + tempConnection.value.path = ''; + }; + + const canConnect = (source: { nodeId: string, portType: string }, target: { nodeId: string, portType: string }): boolean => { + // 不能连接自己 + if (source.nodeId === target.nodeId) return false; + + // 必须是输出端口连接到输入端口 + if (source.portType === target.portType) return false; + + // 确定源和目标 + const sourceNodeId = source.portType === 'output' ? source.nodeId : target.nodeId; + const targetNodeId = source.portType === 'output' ? target.nodeId : source.nodeId; + + // 检查是否会创建循环 + if (wouldCreateCycle(sourceNodeId, targetNodeId)) return false; + + // 检查目标节点是否已经有父节点 + const targetNode = getNodeByIdLocal(targetNodeId); + if (targetNode && targetNode.parent) return false; + + return true; + }; + + const wouldCreateCycle = (sourceId: string, targetId: string): boolean => { + const visited = new Set(); + + const checkAncestors = (nodeId: string): boolean => { + if (visited.has(nodeId)) return false; + visited.add(nodeId); + + if (nodeId === sourceId) return true; + + const node = getNodeByIdLocal(nodeId); + if (node && node.parent) { + return checkAncestors(node.parent); + } + + return false; + }; + + return checkAncestors(targetId); + }; + + const createConnection = (sourceId: string, targetId: string) => { + const sourceNode = getNodeByIdLocal(sourceId); + const targetNode = getNodeByIdLocal(targetId); + + if (!sourceNode || !targetNode) return; + + // 更新节点关系 + if (!sourceNode.children.includes(targetId)) { + sourceNode.children.push(targetId); + } + targetNode.parent = sourceId; + + // 更新连接数组 + const existingConnection = connections.value.find(conn => + conn.sourceId === sourceId && conn.targetId === targetId + ); + + if (!existingConnection) { + connections.value.push({ + id: `${sourceId}-${targetId}`, + sourceId, + targetId, + active: false, + path: createConnectionPath(sourceNode, targetNode).path + }); + } + + updateConnections(); + }; + + const removeConnection = (sourceId: string, targetId: string) => { + const sourceNode = getNodeByIdLocal(sourceId); + const targetNode = getNodeByIdLocal(targetId); + + if (sourceNode) { + const index = sourceNode.children.indexOf(targetId); + if (index > -1) { + sourceNode.children.splice(index, 1); + } + } + + if (targetNode) { + targetNode.parent = undefined; + } + + connections.value = connections.value.filter(conn => + !(conn.sourceId === sourceId && conn.targetId === targetId) + ); + + updateConnections(); + }; + + const updateConnections = () => { + connections.value.forEach(conn => { + const sourceNode = getNodeByIdLocal(conn.sourceId); + const targetNode = getNodeByIdLocal(conn.targetId); + + if (sourceNode && targetNode) { + conn.path = createConnectionPath(sourceNode, targetNode).path; + } + }); + }; + + const createConnectionPath = (sourceNode: TreeNode, targetNode: TreeNode) => { + const nodeWidth = 150; + const nodeHeight = 100; + + // 源节点的输出端口位置(底部中心) + const sourceX = sourceNode.x + nodeWidth / 2; + const sourceY = sourceNode.y + nodeHeight; + + // 目标节点的输入端口位置(顶部中心) + const targetX = targetNode.x + nodeWidth / 2; + const targetY = targetNode.y; + + // 创建贝塞尔曲线路径 + const controlOffset = Math.abs(targetY - sourceY) * 0.5; + const path = `M ${sourceX} ${sourceY} C ${sourceX} ${sourceY + controlOffset} ${targetX} ${targetY - controlOffset} ${targetX} ${targetY}`; + + return { + id: `${sourceNode.id}-${targetNode.id}`, + path, + active: false, + sourceId: sourceNode.id, + targetId: targetNode.id + }; + }; + + return { + startConnection, + updateTempConnection, + onConnectionDragEnd, + cancelConnection, + createConnection, + removeConnection, + updateConnections, + canConnect + }; +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/connectionUtils.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/connectionUtils.ts new file mode 100644 index 00000000..357b949f --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/connectionUtils.ts @@ -0,0 +1,219 @@ +import { TreeNode, Connection, ConnectionPort } from '../types'; +import { getNodeById } from './nodeUtils'; + +/** + * 创建连接路径(贝塞尔曲线) + */ +export function createConnectionPath(sourceNode: TreeNode, targetNode: TreeNode): Connection { + const nodeWidth = 150; + const nodeHeight = 100; + + // 源节点的输出端口位置(底部中心) + const sourceX = sourceNode.x + nodeWidth / 2; + const sourceY = sourceNode.y + nodeHeight; + + // 目标节点的输入端口位置(顶部中心) + const targetX = targetNode.x + nodeWidth / 2; + const targetY = targetNode.y; + + // 创建贝塞尔曲线路径 + const controlOffset = Math.abs(targetY - sourceY) * 0.5; + const path = `M ${sourceX} ${sourceY} C ${sourceX} ${sourceY + controlOffset} ${targetX} ${targetY - controlOffset} ${targetX} ${targetY}`; + + return { + id: `${sourceNode.id}-${targetNode.id}`, + path, + active: false, + sourceId: sourceNode.id, + targetId: targetNode.id + }; +} + +/** + * 创建临时连接路径 + */ +export function createTempConnectionPath( + node: TreeNode, + portType: string, + targetX: number, + targetY: number +): string { + const nodeWidth = 150; + const nodeHeight = 100; + const startX = node.x + nodeWidth / 2; + + let startY: number; + if (portType === 'output') { + startY = node.y + nodeHeight; // 输出端口在底部 + } else { + startY = node.y; // 输入端口在顶部 + } + + // 创建贝塞尔曲线路径 + const controlOffset = Math.abs(targetY - startY) * 0.5; + + let path: string; + if (portType === 'output') { + path = `M ${startX} ${startY} C ${startX} ${startY + controlOffset} ${targetX} ${targetY - controlOffset} ${targetX} ${targetY}`; + } else { + path = `M ${startX} ${startY} C ${startX} ${startY - controlOffset} ${targetX} ${targetY + controlOffset} ${targetX} ${targetY}`; + } + + return path; +} + +/** + * 检查两个端口是否可以连接 + */ +export function canConnect( + source: ConnectionPort, + target: ConnectionPort, + nodes: TreeNode[] +): boolean { + // 不能连接自己 + if (source.nodeId === target.nodeId) return false; + + // 必须是输出端口连接到输入端口 + if (source.portType === target.portType) return false; + + // 确定源和目标 + const sourceNodeId = source.portType === 'output' ? source.nodeId : target.nodeId; + const targetNodeId = source.portType === 'output' ? target.nodeId : source.nodeId; + + // 检查是否会创建循环 + if (wouldCreateCycle(sourceNodeId, targetNodeId, nodes)) return false; + + // 检查目标节点是否已经有父节点 + const targetNode = getNodeById(nodes, targetNodeId); + if (targetNode && targetNode.parent) return false; + + return true; +} + +/** + * 检查是否会创建循环引用 + */ +export function wouldCreateCycle( + sourceId: string, + targetId: string, + nodes: TreeNode[] +): boolean { + const visited = new Set(); + + const checkAncestors = (nodeId: string): boolean => { + if (visited.has(nodeId)) return false; + visited.add(nodeId); + + if (nodeId === sourceId) return true; + + const node = getNodeById(nodes, nodeId); + if (node && node.parent) { + return checkAncestors(node.parent); + } + + return false; + }; + + return checkAncestors(targetId); +} + +/** + * 创建节点间的连接 + */ +export function createConnection( + sourceId: string, + targetId: string, + nodes: TreeNode[], + connections: Connection[] +): { updatedNodes: TreeNode[]; updatedConnections: Connection[] } { + const sourceNode = getNodeById(nodes, sourceId); + const targetNode = getNodeById(nodes, targetId); + + if (!sourceNode || !targetNode) { + return { updatedNodes: nodes, updatedConnections: connections }; + } + + // 更新节点关系 + if (!sourceNode.children.includes(targetId)) { + sourceNode.children.push(targetId); + } + targetNode.parent = sourceId; + + // 更新连接数组 + const existingConnection = connections.find(conn => + conn.sourceId === sourceId && conn.targetId === targetId + ); + + if (!existingConnection) { + const newConnection = createConnectionPath(sourceNode, targetNode); + connections.push(newConnection); + } + + return { + updatedNodes: [...nodes], + updatedConnections: [...connections] + }; +} + +/** + * 移除连接 + */ +export function removeConnection( + sourceId: string, + targetId: string, + nodes: TreeNode[], + connections: Connection[] +): { updatedNodes: TreeNode[]; updatedConnections: Connection[] } { + const sourceNode = getNodeById(nodes, sourceId); + const targetNode = getNodeById(nodes, targetId); + + if (sourceNode) { + const index = sourceNode.children.indexOf(targetId); + if (index > -1) { + sourceNode.children.splice(index, 1); + } + } + + if (targetNode) { + targetNode.parent = undefined; + } + + const updatedConnections = connections.filter(conn => + !(conn.sourceId === sourceId && conn.targetId === targetId) + ); + + return { + updatedNodes: [...nodes], + updatedConnections: updatedConnections + }; +} + +/** + * 更新所有连接的路径 + */ +export function updateAllConnections(connections: Connection[], nodes: TreeNode[]): Connection[] { + return connections.map(conn => { + const sourceNode = getNodeById(nodes, conn.sourceId); + const targetNode = getNodeById(nodes, conn.targetId); + + if (sourceNode && targetNode) { + const updatedConnection = createConnectionPath(sourceNode, targetNode); + return { ...conn, path: updatedConnection.path }; + } + + return conn; + }); +} + +/** + * 从元素中获取节点ID + */ +export function getNodeIdFromElement(element: HTMLElement): string | null { + let current = element; + while (current && current.getAttribute) { + const nodeId = current.getAttribute('data-node-id'); + if (nodeId) return nodeId; + current = current.parentElement as HTMLElement; + } + return null; +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/dragUtils.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/dragUtils.ts new file mode 100644 index 00000000..246839da --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/dragUtils.ts @@ -0,0 +1,229 @@ +import { DragState, TreeNode } from '../types'; +import { getCanvasCoordinates } from './canvasUtils'; +import { createTempConnectionPath, updateAllConnections } from './connectionUtils'; +import { getNodeById } from './nodeUtils'; + +/** + * 节流函数工厂 + */ +export function createThrottledFunction void>( + fn: T, + delay: number = 16 +): (...args: Parameters) => void { + let timeoutId: number | null = null; + + return (...args: Parameters) => { + if (timeoutId) { + cancelAnimationFrame(timeoutId); + } + timeoutId = requestAnimationFrame(() => { + fn(...args); + }); + }; +} + +/** + * 处理画布鼠标移动事件 + */ +export function handleCanvasMouseMove( + event: MouseEvent, + dragState: DragState, + panX: { value: number }, + panY: { value: number }, + zoomLevel: number, + nodes: TreeNode[], + updateConnections: () => void, + updateTempConnection: (nodeId: string, portType: string, x: number, y: number) => void +): void { + if (dragState.isDraggingCanvas) { + const deltaX = event.clientX - dragState.dragStartX; + const deltaY = event.clientY - dragState.dragStartY; + + panX.value += deltaX; + panY.value += deltaY; + + dragState.dragStartX = event.clientX; + dragState.dragStartY = event.clientY; + + } else if (dragState.isDraggingNode && dragState.dragNodeId) { + const node = getNodeById(nodes, dragState.dragNodeId); + if (node) { + const deltaX = (event.clientX - dragState.dragStartX) / zoomLevel; + const deltaY = (event.clientY - dragState.dragStartY) / zoomLevel; + + node.x = dragState.dragNodeStartX + deltaX; + node.y = dragState.dragNodeStartY + deltaY; + + updateConnections(); + } + + } else if (dragState.isConnecting && dragState.connectionStart) { + let canvasElement = event.currentTarget as HTMLElement | null; + if (!canvasElement) { + canvasElement = document.querySelector('.canvas-area') as HTMLElement | null; + } + + if (canvasElement) { + const { x, y } = getCanvasCoordinates(event, canvasElement, panX.value, panY.value, zoomLevel); + + dragState.connectionEnd.x = x; + dragState.connectionEnd.y = y; + + updateTempConnection( + dragState.connectionStart.nodeId, + dragState.connectionStart.portType, + x, + y + ); + } + } +} + +/** + * 开始节点拖拽 + */ +export function startNodeDrag( + event: MouseEvent, + node: TreeNode, + dragState: DragState, + selectNode: (id: string) => void +): void { + // 检查是否点击的是端口或删除按钮 + const target = event.target as HTMLElement; + if (target.classList.contains('port') || + target.classList.contains('node-delete') || + target.closest('.port') || + target.closest('.node-delete')) { + return; // 不启动节点拖拽 + } + + event.stopPropagation(); + + if (event.button !== 0) return; // 只处理左键 + + dragState.isDraggingNode = true; + dragState.dragNodeId = node.id; + dragState.dragStartX = event.clientX; + dragState.dragStartY = event.clientY; + dragState.dragNodeStartX = node.x; + dragState.dragNodeStartY = node.y; + + const nodeElement = document.querySelector(`[data-node-id="${node.id}"]`) as HTMLElement; + if (nodeElement) { + nodeElement.style.transition = 'none'; + } + + selectNode(node.id); +} + +/** + * 开始连接拖拽 + */ +export function startConnection( + event: MouseEvent, + nodeId: string, + portType: string, + dragState: DragState, + nodes: TreeNode[], + panX: number, + panY: number, + zoomLevel: number, + updateTempConnection: (nodeId: string, portType: string, x: number, y: number) => void +): void { + event.stopPropagation(); + event.preventDefault(); + + const node = getNodeById(nodes, nodeId); + if (!node) return; + + dragState.isConnecting = true; + dragState.connectionStart = { nodeId, portType }; + + const canvasElement = document.querySelector('.canvas-area') as HTMLElement | null; + if (canvasElement) { + const { x, y } = getCanvasCoordinates(event, canvasElement, panX, panY, zoomLevel); + dragState.connectionEnd.x = x; + dragState.connectionEnd.y = y; + + updateTempConnection(nodeId, portType, x, y); + } else { + const target = event.target as HTMLElement; + const rect = target.getBoundingClientRect(); + dragState.connectionEnd.x = event.clientX - rect.left; + dragState.connectionEnd.y = event.clientY - rect.top; + + updateTempConnection(nodeId, portType, dragState.connectionEnd.x, dragState.connectionEnd.y); + } +} + +/** + * 处理鼠标释放事件 + */ +export function handleMouseUp( + event: MouseEvent, + dragState: DragState, + cleanupDrag: () => void +): void { + if (dragState.isDraggingCanvas) { + dragState.isDraggingCanvas = false; + + } else if (dragState.isDraggingNode) { + // 恢复过渡效果 + if (dragState.dragNodeId) { + const nodeElement = document.querySelector(`[data-node-id="${dragState.dragNodeId}"]`) as HTMLElement; + if (nodeElement) { + nodeElement.style.transition = ''; + } + } + + dragState.isDraggingNode = false; + dragState.dragNodeId = null; + + } else if (dragState.isConnecting) { + // 连接拖拽结束的处理由外部函数处理 + return; + } + + cleanupDrag(); +} + +/** + * 取消连接拖拽 + */ +export function cancelConnection( + dragState: DragState, + tempConnection: { path: string } +): void { + dragState.isConnecting = false; + dragState.connectionStart = null; + tempConnection.path = ''; +} + +/** + * 创建更新临时连接的函数 + */ +export function createTempConnectionUpdater( + nodes: TreeNode[], + tempConnection: { path: string } +) { + return (nodeId: string, portType: string, targetX: number, targetY: number) => { + const node = getNodeById(nodes, nodeId); + if (!node) return; + + tempConnection.path = createTempConnectionPath(node, portType, targetX, targetY); + }; +} + +/** + * 创建节流版本的连接更新函数 + */ +export function createThrottledConnectionUpdater( + connections: any[], + nodes: TreeNode[], + onUpdate: (connections: any[]) => void +) { + return createThrottledFunction(() => { + const updatedConnections = updateAllConnections(connections, nodes); + onUpdate(updatedConnections); + }); +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/installUtils.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/installUtils.ts new file mode 100644 index 00000000..745bda10 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/installUtils.ts @@ -0,0 +1,110 @@ +import { InstallStatus } from '../types'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 检查行为树AI系统是否已安装 + * 通过主进程检查项目中是否安装了@esengine/ai包 + */ +export async function checkBehaviorTreeInstalled(projectPath: string): Promise { + try { + // 通过Editor.Message请求主进程检查安装状态 + const isInstalled = await Editor.Message.request('cocos-ecs-extension', 'check-behavior-tree-installed'); + + if (isInstalled) { + // 如果已安装,读取版本信息 + const packageJsonPath = path.join(projectPath, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies }; + const aiPackage = dependencies['@esengine/ai']; + + return { + installed: true, + version: aiPackage || null, + packageExists: true + }; + } + } + + return { + installed: false, + version: null, + packageExists: fs.existsSync(path.join(projectPath, 'package.json')) + }; + } catch (error) { + console.error('检查行为树安装状态失败:', error); + return { + installed: false, + version: null, + packageExists: false + }; + } +} + +/** + * 格式化安装状态文本 + */ +export function getInstallStatusText( + isChecking: boolean, + isInstalling: boolean, + isInstalled: boolean, + version: string | null +): string { + if (isChecking) return '检查中...'; + if (isInstalling) return '安装中...'; + return isInstalled ? `✅ AI系统已安装 (v${version})` : '❌ AI系统未安装'; +} + +/** + * 获取安装状态CSS类 + */ +export function getInstallStatusClass( + isInstalling: boolean, + isInstalled: boolean +): string { + if (isInstalling) return 'installing'; + return isInstalled ? 'installed' : 'not-installed'; +} + +/** + * 安装行为树AI系统 + * 通过发送消息到主进程来执行真实的npm安装命令 + */ +export async function installBehaviorTreeAI(projectPath: string): Promise { + try { + // 通过Editor.Message发送安装消息到主进程 + // 主进程会执行实际的npm install @esengine/ai命令 + const result = await Editor.Message.request('cocos-ecs-extension', 'install-behavior-tree'); + + if (!result) { + throw new Error('安装请求失败,未收到主进程响应'); + } + + console.log('行为树AI系统安装完成'); + } catch (error) { + console.error('行为树AI系统安装失败:', error); + throw error; + } +} + +/** + * 更新行为树AI系统 + * 通过发送消息到主进程来执行真实的npm更新命令 + */ +export async function updateBehaviorTreeAI(projectPath: string): Promise { + try { + // 通过Editor.Message发送更新消息到主进程 + // 主进程会执行实际的npm update @esengine/ai命令 + const result = await Editor.Message.request('cocos-ecs-extension', 'update-behavior-tree'); + + if (!result) { + throw new Error('更新请求失败,未收到主进程响应'); + } + + console.log('行为树AI系统更新完成'); + } catch (error) { + console.error('行为树AI系统更新失败:', error); + throw error; + } +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/nodeUtils.ts b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/nodeUtils.ts new file mode 100644 index 00000000..21f5d1ed --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/source/panels/behavior-tree/utils/nodeUtils.ts @@ -0,0 +1,160 @@ +import { TreeNode, ValidationResult } from '../types'; +import { NodeTemplate } from '../data/nodeTemplates'; + +/** + * 生成唯一的节点ID + */ +export function generateNodeId(): string { + return 'node_' + Math.random().toString(36).substr(2, 9); +} + +/** + * 根据模板创建节点 + */ +export function createNodeFromTemplate(template: NodeTemplate, x: number = 100, y: number = 100): TreeNode { + const nodeId = generateNodeId(); + + // 深拷贝 properties 以避免引用共享 + let properties: any = {}; + if (template.properties) { + for (const [key, prop] of Object.entries(template.properties)) { + properties[key] = { + name: prop.name, + type: prop.type, + value: prop.value, + description: prop.description, + options: prop.options ? [...prop.options] : undefined, + required: prop.required + }; + } + } + + const node: TreeNode = { + id: nodeId, + type: template.className || template.type, + name: template.name, + icon: template.icon, + description: template.description, + x: x, + y: y, + children: [], + properties: properties, + canHaveChildren: template.canHaveChildren, + canHaveParent: template.canHaveParent, + hasError: false + }; + return node; +} + +/** + * 根据ID查找节点 + */ +export function getNodeById(nodes: TreeNode[], id: string): TreeNode | undefined { + return nodes.find(node => node.id === id); +} + +/** + * 获取根节点 + */ +export function getRootNode(nodes: TreeNode[]): TreeNode | undefined { + return nodes.find(node => !node.parent); +} + +/** + * 递归删除节点及其子节点 + */ +export function deleteNodeRecursive( + nodes: TreeNode[], + nodeId: string, + connections: any[], + onConnectionsUpdate: (connections: any[]) => void +): TreeNode[] { + const deleteRecursive = (id: string) => { + const node = getNodeById(nodes, id); + if (!node) return; + + // 递归删除子节点 + node.children.forEach(childId => deleteRecursive(childId)); + + // 从父节点的children中移除 + if (node.parent) { + const parent = getNodeById(nodes, node.parent); + if (parent) { + const index = parent.children.indexOf(id); + if (index > -1) { + parent.children.splice(index, 1); + } + } + } + + // 移除连接 + const updatedConnections = connections.filter(conn => + conn.sourceId !== id && conn.targetId !== id + ); + onConnectionsUpdate(updatedConnections); + + // 从树中移除节点 + const nodeIndex = nodes.findIndex(n => n.id === id); + if (nodeIndex > -1) { + nodes.splice(nodeIndex, 1); + } + }; + + deleteRecursive(nodeId); + return [...nodes]; // 返回新数组以触发响应式更新 +} + +/** + * 验证行为树结构 + */ +export function validateTree(nodes: TreeNode[]): ValidationResult { + if (nodes.length === 0) { + return { isValid: false, message: '行为树为空' }; + } + + const root = getRootNode(nodes); + if (!root) { + return { isValid: false, message: '缺少根节点' }; + } + + // 可以添加更多验证逻辑 + // 例如:检查循环引用、孤立节点等 + + return { isValid: true, message: '行为树结构有效' }; +} + +/** + * 更新节点属性 + */ +export function updateNodeProperty(node: TreeNode, path: string, value: any): void { + if (!node.properties) return; + + const keys = path.split('.'); + let target: any = node.properties; + + for (let i = 0; i < keys.length - 1; i++) { + target = target[keys[i]]; + } + + target[keys[keys.length - 1]] = value; +} + +/** + * 计算节点的边界框 + */ +export function getNodesBounds(nodes: TreeNode[]): { minX: number; minY: number; maxX: number; maxY: number } { + if (nodes.length === 0) { + return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; + } + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + nodes.forEach(node => { + minX = Math.min(minX, node.x); + minY = Math.min(minY, node.y); + maxX = Math.max(maxX, node.x + 150); // 节点宽度 + maxY = Math.max(maxY, node.y + 100); // 节点高度 + }); + + return { minX, minY, maxX, maxY }; +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/style/behavior-tree/index.css b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/style/behavior-tree/index.css new file mode 100644 index 00000000..1c60afc5 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/style/behavior-tree/index.css @@ -0,0 +1,902 @@ +/* 基础样式 */ +#behavior-tree-app { + display: flex; + flex-direction: column; + height: 100vh; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: #1e1e1e; + color: #ffffff; + overflow: hidden; +} + +/* 头部工具栏 */ +.header-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 20px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-bottom: 2px solid #4a5568; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +.toolbar-left { + display: flex; + align-items: center; + gap: 20px; +} + +.toolbar-left h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + text-shadow: 0 2px 4px rgba(0,0,0,0.3); +} + +.toolbar-buttons { + display: flex; + gap: 8px; +} + +.tool-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + background: rgba(255,255,255,0.1); + border: 1px solid rgba(255,255,255,0.2); + border-radius: 6px; + color: white; + cursor: pointer; + transition: all 0.3s ease; + font-size: 12px; +} + +.tool-btn:hover { + background: rgba(255,255,255,0.2); + transform: translateY(-1px); +} + +.toolbar-right .install-status { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 16px; + background: rgba(255,255,255,0.1); + border-radius: 8px; +} + +/* 编辑器容器 */ +.editor-container { + display: flex; + flex: 1; + overflow: hidden; +} + +/* 左侧节点面板 */ +.nodes-panel { + width: 280px; + background: #2d3748; + border-right: 1px solid #4a5568; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-header { + padding: 16px; + background: #4a5568; + border-bottom: 1px solid #718096; +} + +.panel-header h3 { + margin: 0 0 12px 0; + font-size: 16px; + color: #e2e8f0; +} + +.search-input { + width: 100%; + padding: 8px 12px; + background: #1a202c; + border: 1px solid #4a5568; + border-radius: 4px; + color: white; + font-size: 12px; +} + +.search-input::placeholder { + color: #a0aec0; +} + +.node-categories { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.category { + margin-bottom: 16px; +} + +.category-title { + margin: 0 0 8px 0; + padding: 8px 12px; + background: #4a5568; + border-radius: 4px; + font-size: 14px; + color: #e2e8f0; +} + +.node-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.node-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: #1a202c; + border: 1px solid #4a5568; + border-radius: 4px; + cursor: grab; + transition: all 0.3s ease; + font-size: 12px; +} + +.node-item:hover { + background: #2d3748; + border-color: #667eea; + transform: translateX(4px); +} + +.node-item:active { + cursor: grabbing; +} + +.node-item.composite { border-left: 3px solid #667eea; } +.node-item.decorator { border-left: 3px solid #9f7aea; } +.node-item.action { border-left: 3px solid #48bb78; } +.node-item.condition { border-left: 3px solid #ed8936; } +.node-item.ecs { border-left: 3px solid #38b2ac; } + +.node-icon { + font-size: 16px; + min-width: 20px; +} + +.node-name { + flex: 1; + color: #e2e8f0; +} + +/* 中间画布区域 */ +.canvas-container { + flex: 1; + display: flex; + flex-direction: column; + background: #1a202c; + position: relative; +} + +.canvas-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + background: #2d3748; + border-bottom: 1px solid #4a5568; +} + +.zoom-controls, .canvas-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.zoom-controls button, .canvas-actions button { + padding: 4px 8px; + background: #4a5568; + border: 1px solid #718096; + border-radius: 4px; + color: white; + cursor: pointer; + font-size: 12px; + transition: background 0.3s ease; +} + +.zoom-controls button:hover, .canvas-actions button:hover { + background: #667eea; +} + +.canvas-area { + flex: 1; + position: relative; + overflow: hidden; + background: + radial-gradient(circle at 20px 20px, #4a5568 1px, transparent 1px), + linear-gradient(90deg, transparent 19px, #2d3748 20px, transparent 21px), + linear-gradient(transparent 19px, #2d3748 20px, transparent 21px); + background-size: 20px 20px; +} + +.behavior-tree-canvas { + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} + +.connection-layer { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + z-index: 1; + will-change: transform; +} + +.connection-line { + stroke: #67b7dc; + stroke-width: 3; + fill: none; + transition: none; + opacity: 0.9; + will-change: d; +} + +.connection-line:hover { + stroke: #9f7aea; + stroke-width: 4; + opacity: 1; +} + +.connection-active { + stroke: #48bb78; + stroke-width: 4; + animation: flow 1.5s linear infinite; +} + +@keyframes flow { + 0% { stroke-dasharray: 8,8; stroke-dashoffset: 0; } + 100% { stroke-dasharray: 8,8; stroke-dashoffset: 16; } +} + +.connection-temp { + stroke: #f6ad55; + stroke-width: 3; + stroke-dasharray: 6,6; + opacity: 0.8; + animation: dash 1s linear infinite; + will-change: d; + transition: none; +} + +@keyframes dash { + 0% { stroke-dashoffset: 0; } + 100% { stroke-dashoffset: 12; } +} + +.nodes-layer { + position: absolute; + top: 0; + left: 0; + z-index: 2; + transform-origin: 0 0; + will-change: transform; +} + +.tree-node { + position: absolute; + min-width: 150px; + background: linear-gradient(145deg, #2d3748 0%, #1a202c 100%); + border: 2px solid #4a5568; + border-radius: 12px; + cursor: pointer; + user-select: none; + transition: all 0.2s ease; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + will-change: transform; + backface-visibility: hidden; + transform-style: preserve-3d; +} + +.tree-node:hover { + border-color: #67b7dc; + box-shadow: 0 6px 20px rgba(103, 183, 220, 0.3); + transform: translateY(-1px); +} + +.tree-node.node-selected { + border-color: #48bb78; + box-shadow: 0 0 0 3px rgba(72, 187, 120, 0.3); + background: linear-gradient(145deg, #2f4f4f 0%, #1e3a3a 100%); +} + +.tree-node.node-error { + border-color: #f56565; + box-shadow: 0 0 0 3px rgba(245, 101, 101, 0.3); + background: linear-gradient(145deg, #4a2626 0%, #2d1a1a 100%); +} + +.node-header { + display: flex; + align-items: center; + padding: 10px 14px; + background: rgba(0,0,0,0.2); + border-bottom: 1px solid #4a5568; + border-radius: 10px 10px 0 0; + backdrop-filter: blur(2px); +} + +.node-header .node-icon { + font-size: 16px; + margin-right: 10px; + opacity: 0.9; +} + +.node-title { + flex: 1; + font-size: 13px; + font-weight: 600; + color: #e2e8f0; + text-shadow: 0 1px 2px rgba(0,0,0,0.3); +} + +.node-delete { + width: 20px; + height: 20px; + background: #f56565; + border: none; + border-radius: 50%; + color: white; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.7; + transition: opacity 0.3s ease; +} + +.node-delete:hover { + opacity: 1; +} + +.node-body { + padding: 10px 14px; +} + +.node-description { + font-size: 11px; + color: #a0aec0; + line-height: 1.4; + opacity: 0.9; +} + +/* 端口基础样式 */ +.port { + position: absolute; + width: 16px; + height: 16px; + border-radius: 2px; + border: 2px solid; + cursor: pointer; + transition: all 0.2s ease; + z-index: 100; + left: 50%; + pointer-events: auto; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + backdrop-filter: blur(1px); +} + +/* 增加端口的可点击区域 */ +.port::before { + content: ''; + position: absolute; + top: -10px; + left: -10px; + right: -10px; + bottom: -10px; + z-index: 101; + pointer-events: auto; +} + +.port:hover { + transform: scale(1.3); + box-shadow: 0 0 8px rgba(0, 0, 0, 0.3); +} + +/* 输入端口 - 执行流入口,蓝色方形 */ +.port-input { + top: -8px; + transform: translateX(-50%); + background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%); + border-color: #2c5aa0; +} + +.port-input:hover { + background: linear-gradient(135deg, #5ba3f5 0%, #4a90e2 100%); + border-color: #357abd; + box-shadow: 0 0 12px rgba(74, 144, 226, 0.6); + transform: translateX(-50%) scale(1.3); +} + +/* 输出端口 - 执行流出口,橙色方形 */ +.port-output { + bottom: -8px; + transform: translateX(-50%); + background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%); + border-color: #d68910; +} + +.port-output:hover { + background: linear-gradient(135deg, #f5b041 0%, #f39c12 100%); + border-color: #e67e22; + box-shadow: 0 0 12px rgba(243, 156, 18, 0.6); + transform: translateX(-50%) scale(1.3); +} + +/* 连接中的端口高亮 - 更清晰的反馈 */ +.port.connecting { + background: linear-gradient(135deg, #ffd700 0%, #ffb347 100%) !important; + border-color: #ff8c00 !important; + box-shadow: 0 0 15px rgba(255, 215, 0, 0.8) !important; + animation: pulse-port 1s infinite; +} + +@keyframes pulse-port { + 0% { + transform: translateX(-50%) scale(1.2); + box-shadow: 0 0 15px rgba(255, 215, 0, 0.8); + } + 50% { + transform: translateX(-50%) scale(1.4); + box-shadow: 0 0 20px rgba(255, 215, 0, 1); + } + 100% { + transform: translateX(-50%) scale(1.2); + box-shadow: 0 0 15px rgba(255, 215, 0, 0.8); + } +} + +/* 拖拽目标样式 - 绿色高亮表示可连接 */ +.port.drag-target { + background: linear-gradient(135deg, #48bb78 0%, #38a169 100%) !important; + border-color: #2f855a !important; + transform: scale(1.4) !important; + box-shadow: 0 0 15px rgba(72, 187, 120, 0.8) !important; + animation: pulse-target 0.8s infinite; +} + +@keyframes pulse-target { + 0% { + transform: scale(1.4); + box-shadow: 0 0 15px rgba(72, 187, 120, 0.8); + } + 50% { + transform: scale(1.6); + box-shadow: 0 0 20px rgba(72, 187, 120, 1); + } + 100% { + transform: scale(1.4); + box-shadow: 0 0 15px rgba(72, 187, 120, 0.8); + } +} + +/* 端口内部亮点效果 */ +.port-inner { + position: absolute; + top: 3px; + left: 3px; + right: 3px; + bottom: 3px; + border-radius: 1px; + background: rgba(255, 255, 255, 0.2); + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.port:hover .port-inner { + opacity: 1; + background: rgba(255, 255, 255, 0.4); +} + +.port.connecting .port-inner { + opacity: 1; + background: rgba(255, 255, 255, 0.6); +} + +.port.drag-target .port-inner { + opacity: 1; + background: rgba(255, 255, 255, 0.8); +} + +.children-indicator { + position: absolute; + bottom: -12px; + right: 8px; + width: 20px; + height: 20px; + background: #667eea; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: bold; + color: white; +} + +/* 右侧属性面板 */ +.properties-panel { + width: 320px; + background: #2d3748; + border-left: 1px solid #4a5568; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.node-properties { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.property-section { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid #4a5568; +} + +.property-section h4 { + margin: 0 0 12px 0; + font-size: 14px; + color: #e2e8f0; +} + +.property-item { + margin-bottom: 12px; +} + +.property-item label { + display: block; + margin-bottom: 4px; + font-size: 12px; + color: #a0aec0; +} + +.property-item input, +.property-item textarea, +.property-item select { + flex: 1; + padding: 6px 8px; + background: #1a202c; + border: 1px solid #4a5568; + border-radius: 4px; + color: white; + font-size: 12px; +} + +.property-item input:focus, +.property-item textarea:focus, +.property-item select:focus { + border-color: #667eea; + outline: none; +} + +.property-item .code-input { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 11px; + line-height: 1.4; + resize: vertical; + min-height: 120px; +} + +.property-help { + margin: 4px 0 0 0; + font-size: 10px; + color: #a0aec0; + font-style: italic; +} + +.code-preview { + background: #1a202c; + border: 1px solid #4a5568; + border-radius: 4px; + padding: 12px; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 11px; + color: #e2e8f0; + white-space: pre-wrap; + overflow-x: auto; +} + +.no-selection { + padding: 40px 20px; + text-align: center; + color: #a0aec0; +} + +.tree-structure { + padding: 16px; + border-top: 1px solid #4a5568; + background: #1a202c; +} + +.structure-tree { + font-family: monospace; + font-size: 12px; +} + +.empty-tree { + color: #a0aec0; + font-style: italic; + text-align: center; + padding: 20px; +} + +.tree-node-item { + margin-bottom: 2px; +} + +.tree-node-line { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 3px; + cursor: pointer; + transition: background 0.2s ease; + font-size: 11px; +} + +.tree-node-line:hover { + background: #4a5568; +} + +.tree-node-icon { + font-size: 12px; + min-width: 16px; +} + +.tree-node-name { + flex: 1; + color: #e2e8f0; + font-weight: 500; +} + +.tree-node-type { + color: #a0aec0; + font-size: 10px; +} + +/* 底部状态栏 */ +.status-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 20px; + background: #2d3748; + border-top: 1px solid #4a5568; + font-size: 12px; + color: #a0aec0; +} + +.status-left, .status-right { + display: flex; + gap: 16px; +} + +.status-valid { + color: #48bb78; +} + +.status-invalid { + color: #f56565; +} + +/* 模态框 */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-content { + background: #2d3748; + border-radius: 8px; + width: 90%; + max-width: 800px; + max-height: 90%; + display: flex; + flex-direction: column; + box-shadow: 0 20px 40px rgba(0,0,0,0.5); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #4a5568; +} + +.modal-header h3 { + margin: 0; + font-size: 18px; + color: #e2e8f0; +} + +.modal-header button { + background: none; + border: none; + font-size: 24px; + color: #a0aec0; + cursor: pointer; +} + +.modal-body { + flex: 1; + padding: 20px; + overflow-y: auto; +} + +.export-options { + display: flex; + gap: 20px; + margin-bottom: 20px; +} + +.export-options label { + display: flex; + align-items: center; + gap: 8px; + color: #e2e8f0; + cursor: pointer; +} + +.code-output { + background: #1a202c; + border: 1px solid #4a5568; + border-radius: 4px; + padding: 16px; + height: 400px; + overflow: auto; +} + +.code-output pre { + margin: 0; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 12px; + color: #e2e8f0; + white-space: pre-wrap; +} + +.modal-footer { + display: flex; + gap: 12px; + padding: 16px 20px; + border-top: 1px solid #4a5568; + justify-content: flex-end; +} + +.modal-footer button { + padding: 8px 16px; + background: #667eea; + border: none; + border-radius: 4px; + color: white; + cursor: pointer; + font-size: 12px; + transition: background 0.3s ease; +} + +.modal-footer button:hover { + background: #5a67d8; +} + +/* 响应式设计 */ +@media (max-width: 1200px) { + .nodes-panel { + width: 240px; + } + + .properties-panel { + width: 280px; + } +} + +@media (max-width: 900px) { + .editor-container { + flex-direction: column; + } + + .nodes-panel, .properties-panel { + width: 100%; + height: 200px; + } + + .canvas-container { + flex: 1; + min-height: 400px; + } +} + +/* 动画效果 */ +@keyframes nodeAppear { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.tree-node { + animation: nodeAppear 0.3s ease-out; +} + +/* 拖拽相关样式 */ +.drag-over { + background: rgba(102, 126, 234, 0.1); + border: 2px dashed #667eea; +} + +/* 拖动状态样式 */ +.tree-node.dragging { + opacity: 0.9; + transform: scale(1.02) rotate(1deg); + box-shadow: 0 12px 30px rgba(0,0,0,0.4); + z-index: 1000; + cursor: grabbing; + will-change: transform, opacity; + backface-visibility: hidden; + transform-style: preserve-3d; + transition: none; + border-color: #67b7dc; +} + +/* 节点悬停时的端口显示优化 */ +.tree-node:hover .port { + opacity: 1; + transform: translateX(-50%) scale(1.1); +} + +.tree-node .port { + opacity: 0.8; + transition: all 0.2s ease; +} + +/* 连接状态下的cursor样式 */ +.canvas-area.connecting { + cursor: crosshair; +} + +.canvas-area.connecting .tree-node { + pointer-events: none; +} + +.canvas-area.connecting .port { + pointer-events: all; + cursor: pointer; +} + +.dragging { + opacity: 0.7; + transform: rotate(5deg); +} \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/template/behavior-tree/BehaviorTreeEditor.html b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/template/behavior-tree/BehaviorTreeEditor.html new file mode 100644 index 00000000..c873bb9a --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/template/behavior-tree/BehaviorTreeEditor.html @@ -0,0 +1,407 @@ + +
+
+

🌳 行为树可视化编辑器

+
+ + + + +
+
+
+
+ {{ installStatusText() }} + +
+
+
+ +
+ +
+
+

📦 节点库

+ +
+ +
+ +
+

🔗 复合节点

+
+
+ {{ node.icon }} + {{ node.name }} +
+
+
+ + +
+

🎭 装饰器

+
+
+ {{ node.icon }} + {{ node.name }} +
+
+
+ + +
+

⚡ 动作节点

+
+
+ {{ node.icon }} + {{ node.name }} +
+
+
+ + +
+

❓ 条件节点

+
+
+ {{ node.icon }} + {{ node.name }} +
+
+
+ + +
+

🎮 ECS节点

+
+
+ {{ node.icon }} + {{ node.name }} +
+
+
+
+
+ + +
+
+
+ + {{ Math.round(zoomLevel * 100) }}% + + +
+
+ + + +
+
+ +
+ + + + + + + + + + + + +
+
+
+ {{ node.icon }} + {{ node.name }} + +
+
+
{{ node.description }}
+
+ +
+
+
+ + +
+
+
+ +
+ {{ node.children.length }} +
+
+
+ + +
+
+
+ + +
+
+

⚙️ 属性面板

+
+ +
+
+

基本信息

+
+ + +
+
+ + +
+
+ +
+

节点属性

+
+ + + + + + +

{{ prop.description }}

+
+
+ +
+

代码预览

+
{{ generateNodeCode(selectedNode()) }}
+
+
+ +
+

请选择一个节点查看属性

+
+
+
+ + +
+
+

🌲 树结构

+
+
+ +
+
+ + +
+ ⚠️ + {{ validationResult().message }} +
+ + + \ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/template/behavior-tree/TreeNodeItem.html b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/template/behavior-tree/TreeNodeItem.html new file mode 100644 index 00000000..493bef85 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/template/behavior-tree/TreeNodeItem.html @@ -0,0 +1,16 @@ +
+
+ {{ node.icon }} + {{ node.name }} + ({{ node.type }}) +
+ +
\ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/template/behavior-tree/index.html b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/template/behavior-tree/index.html new file mode 100644 index 00000000..f9315b48 --- /dev/null +++ b/extensions/cocos/cocos-ecs/extensions/cocos-ecs-extension/static/template/behavior-tree/index.html @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/extensions/cocos/cocos-ecs/package-lock.json b/extensions/cocos/cocos-ecs/package-lock.json index 6339e326..a7fbdcaa 100644 --- a/extensions/cocos/cocos-ecs/package-lock.json +++ b/extensions/cocos/cocos-ecs/package-lock.json @@ -6,9 +6,18 @@ "": { "name": "cocos-ecs", "dependencies": { + "@esengine/ai": "^2.0.1", "@esengine/ecs-framework": "^2.1.19" } }, + "node_modules/@esengine/ai": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@esengine/ai/-/ai-2.0.1.tgz", + "integrity": "sha512-qGGYc4kYlSJzCkBDJa+p5OruOnDvnL2oJ/ciKSHsPJVdn1tIefPEkUofJyMVGo4my5ubGr2ky6igTLtLYmhzRg==", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@esengine/ecs-framework": { "version": "2.1.20", "resolved": "https://registry.npmjs.org/@esengine/ecs-framework/-/ecs-framework-2.1.20.tgz", diff --git a/extensions/cocos/cocos-ecs/package.json b/extensions/cocos/cocos-ecs/package.json index 5e026a30..c35b2052 100644 --- a/extensions/cocos/cocos-ecs/package.json +++ b/extensions/cocos/cocos-ecs/package.json @@ -5,6 +5,7 @@ "version": "3.8.6" }, "dependencies": { + "@esengine/ai": "^2.0.1", "@esengine/ecs-framework": "^2.1.19" } } diff --git a/thirdparty/BehaviourTree-ai b/thirdparty/BehaviourTree-ai new file mode 160000 index 00000000..73c1d773 --- /dev/null +++ b/thirdparty/BehaviourTree-ai @@ -0,0 +1 @@ +Subproject commit 73c1d77324c5f0c23817f16af6105e8de8e97c47