新增行为树编辑器
This commit is contained in:
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "thirdparty/BehaviourTree-ai"]
|
||||
path = thirdparty/BehaviourTree-ai
|
||||
url = https://github.com/esengine/BehaviourTree-ai.git
|
||||
@@ -1 +1,15 @@
|
||||
"use strict";module.exports={open_panel:"默认面板",send_to_panel:"发送消息给面板",description:"专业的ECS框架开发助手:一键安装@esengine/ecs-framework,智能代码生成器快速创建组件和系统,项目模板生成,实时状态检测和版本管理。提供欢迎面板、调试面板和代码生成器,让Cocos Creator的ECS开发更高效便捷。"};
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
// 插件描述
|
||||
description: "专业的ECS框架开发助手:一键安装@esengine/ecs-framework,智能代码生成器快速创建组件和系统,项目模板生成,实时状态检测和版本管理。提供欢迎面板、调试面板、代码生成器和行为树AI组件库,让Cocos Creator的ECS开发更高效便捷。",
|
||||
|
||||
// 面板相关
|
||||
open_panel: "默认面板",
|
||||
send_to_panel: "发送消息给面板",
|
||||
|
||||
// 菜单相关
|
||||
menu: {
|
||||
panel: "面板"
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>((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<void>((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}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const isInstalling = ref(false);
|
||||
|
||||
// 编辑器状态
|
||||
const nodeTemplates_ = ref(nodeTemplates);
|
||||
const treeNodes = ref<TreeNode[]>([]);
|
||||
const selectedNodeId = ref<string | null>(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<DragState>({
|
||||
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<Connection[]>([]);
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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<HTMLElement | null>(null);
|
||||
const svgRef = ref<SVGElement | null>(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<string>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Ref } from 'vue';
|
||||
import { TreeNode } from '../types';
|
||||
import { NodeTemplate } from '../data/nodeTemplates';
|
||||
|
||||
/**
|
||||
* 代码生成管理
|
||||
*/
|
||||
export function useCodeGeneration(
|
||||
treeNodes: Ref<TreeNode[]>,
|
||||
nodeTemplates: Ref<NodeTemplate[]>,
|
||||
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<string>();
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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<NodeTemplate[]>,
|
||||
nodeSearchText: Ref<string>,
|
||||
treeNodes: Ref<TreeNode[]>,
|
||||
selectedNodeId: Ref<string | null>,
|
||||
checkingStatus: Ref<boolean>,
|
||||
isInstalling: Ref<boolean>,
|
||||
isInstalled: Ref<boolean>,
|
||||
version: Ref<string | null>,
|
||||
exportFormat: Ref<string>,
|
||||
panX: Ref<number>,
|
||||
panY: Ref<number>,
|
||||
zoomLevel: Ref<number>,
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Ref } from 'vue';
|
||||
import { TreeNode, Connection } from '../types';
|
||||
|
||||
/**
|
||||
* 文件操作管理
|
||||
*/
|
||||
export function useFileOperations(
|
||||
treeNodes: Ref<TreeNode[]>,
|
||||
selectedNodeId: Ref<string | null>,
|
||||
connections: Ref<Connection[]>,
|
||||
tempConnection: Ref<{ path: string }>,
|
||||
showExportModal: Ref<boolean>
|
||||
) {
|
||||
|
||||
// 工具栏操作
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Ref } from 'vue';
|
||||
import { checkBehaviorTreeInstalled, installBehaviorTreeAI } from '../utils/installUtils';
|
||||
|
||||
/**
|
||||
* 安装管理
|
||||
*/
|
||||
export function useInstallation(
|
||||
checkingStatus: Ref<boolean>,
|
||||
isInstalled: Ref<boolean>,
|
||||
version: Ref<string | null>,
|
||||
isInstalling: Ref<boolean>
|
||||
) {
|
||||
|
||||
// 检查安装状态
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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<TreeNode[]>,
|
||||
selectedNodeId: Ref<string | null>,
|
||||
connections: Ref<Connection[]>,
|
||||
panX: Ref<number>,
|
||||
panY: Ref<number>,
|
||||
zoomLevel: Ref<number>,
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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<string, PropertyDefinition>;
|
||||
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'
|
||||
}
|
||||
];
|
||||
@@ -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<any, App>();
|
||||
|
||||
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();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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<string, PropertyDefinition>;
|
||||
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;
|
||||
}
|
||||
@@ -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<number>,
|
||||
canvasHeight: Ref<number>,
|
||||
zoomLevel: Ref<number>,
|
||||
panX: Ref<number>,
|
||||
panY: Ref<number>,
|
||||
dragState: Ref<DragState>,
|
||||
treeNodes: Ref<TreeNode[]>,
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
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 : '未知错误'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<TreeNode[]>,
|
||||
connections: Ref<Connection[]>,
|
||||
tempConnection: Ref<{ path: string }>,
|
||||
dragState: Ref<DragState>,
|
||||
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<string>();
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<T extends (...args: any[]) => void>(
|
||||
fn: T,
|
||||
delay: number = 16
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<InstallStatus> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
<!-- 头部工具栏 -->
|
||||
<div class="header-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h2>🌳 行为树可视化编辑器</h2>
|
||||
<div class="toolbar-buttons">
|
||||
<button class="tool-btn" @click="newBehaviorTree" title="新建行为树">
|
||||
<span>📄</span> 新建
|
||||
</button>
|
||||
<button class="tool-btn" @click="saveBehaviorTree" title="保存行为树">
|
||||
<span>💾</span> 保存
|
||||
</button>
|
||||
<button class="tool-btn" @click="loadBehaviorTree" title="加载行为树">
|
||||
<span>📂</span> 加载
|
||||
</button>
|
||||
<button class="tool-btn" @click="exportCode" title="导出代码">
|
||||
<span>⚡</span> 导出代码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<div class="install-status" :class="installStatusClass()">
|
||||
<span>{{ installStatusText() }}</span>
|
||||
<button v-if="!isInstalled" @click="handleInstall" :disabled="isInstalling">
|
||||
{{ isInstalling ? '安装中...' : '安装AI系统' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
<!-- 左侧节点面板 -->
|
||||
<div class="nodes-panel">
|
||||
<div class="panel-header">
|
||||
<h3>📦 节点库</h3>
|
||||
<input
|
||||
type="text"
|
||||
v-model="nodeSearchText"
|
||||
placeholder="搜索节点..."
|
||||
class="search-input"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="node-categories">
|
||||
<!-- 复合节点 -->
|
||||
<div class="category">
|
||||
<h4 class="category-title">🔗 复合节点</h4>
|
||||
<div class="node-list">
|
||||
<div
|
||||
v-for="node in filteredCompositeNodes()"
|
||||
:key="node.type"
|
||||
class="node-item composite"
|
||||
:draggable="true"
|
||||
@dragstart="onNodeDragStart($event, node)"
|
||||
:title="node.description"
|
||||
>
|
||||
<span class="node-icon">{{ node.icon }}</span>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 装饰器节点 -->
|
||||
<div class="category">
|
||||
<h4 class="category-title">🎭 装饰器</h4>
|
||||
<div class="node-list">
|
||||
<div
|
||||
v-for="node in filteredDecoratorNodes()"
|
||||
:key="node.type"
|
||||
class="node-item decorator"
|
||||
:draggable="true"
|
||||
@dragstart="onNodeDragStart($event, node)"
|
||||
:title="node.description"
|
||||
>
|
||||
<span class="node-icon">{{ node.icon }}</span>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 动作节点 -->
|
||||
<div class="category">
|
||||
<h4 class="category-title">⚡ 动作节点</h4>
|
||||
<div class="node-list">
|
||||
<div
|
||||
v-for="node in filteredActionNodes()"
|
||||
:key="node.type"
|
||||
class="node-item action"
|
||||
:draggable="true"
|
||||
@dragstart="onNodeDragStart($event, node)"
|
||||
:title="node.description"
|
||||
>
|
||||
<span class="node-icon">{{ node.icon }}</span>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 条件节点 -->
|
||||
<div class="category">
|
||||
<h4 class="category-title">❓ 条件节点</h4>
|
||||
<div class="node-list">
|
||||
<div
|
||||
v-for="node in filteredConditionNodes()"
|
||||
:key="node.type"
|
||||
class="node-item condition"
|
||||
:draggable="true"
|
||||
@dragstart="onNodeDragStart($event, node)"
|
||||
:title="node.description"
|
||||
>
|
||||
<span class="node-icon">{{ node.icon }}</span>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ECS节点 -->
|
||||
<div class="category">
|
||||
<h4 class="category-title">🎮 ECS节点</h4>
|
||||
<div class="node-list">
|
||||
<div
|
||||
v-for="node in filteredECSNodes()"
|
||||
:key="node.type"
|
||||
class="node-item ecs"
|
||||
:draggable="true"
|
||||
@dragstart="onNodeDragStart($event, node)"
|
||||
:title="node.description"
|
||||
>
|
||||
<span class="node-icon">{{ node.icon }}</span>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间画布区域 -->
|
||||
<div class="canvas-container">
|
||||
<div class="canvas-toolbar">
|
||||
<div class="zoom-controls">
|
||||
<button @click="zoomIn">🔍+</button>
|
||||
<span>{{ Math.round(zoomLevel * 100) }}%</span>
|
||||
<button @click="zoomOut">🔍-</button>
|
||||
<button @click="resetZoom">重置</button>
|
||||
</div>
|
||||
<div class="canvas-actions">
|
||||
<button @click="centerView">居中</button>
|
||||
<button @click="autoLayout">自动布局</button>
|
||||
<button @click="validateTree">验证</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="canvasAreaRef"
|
||||
class="canvas-area"
|
||||
:class="{ 'connecting': dragState.isConnecting }"
|
||||
@drop="onCanvasDrop"
|
||||
@dragover="onCanvasDragOver"
|
||||
@wheel="onCanvasWheel"
|
||||
@mousedown="onCanvasMouseDown"
|
||||
@mousemove="onCanvasMouseMove"
|
||||
@mouseup="onCanvasMouseUp"
|
||||
>
|
||||
<canvas
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
class="behavior-tree-canvas"
|
||||
></canvas>
|
||||
|
||||
<!-- 连接线绘制层 -->
|
||||
<svg
|
||||
ref="svgRef"
|
||||
class="connection-layer"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
>
|
||||
<g :transform="'translate(' + panX + ', ' + panY + ') scale(' + zoomLevel + ')'">
|
||||
<path
|
||||
v-for="connection in connections"
|
||||
:key="connection.id"
|
||||
:d="connection.path"
|
||||
class="connection-line"
|
||||
:class="{ 'connection-active': connection.active }"
|
||||
/>
|
||||
<!-- 临时连接线 -->
|
||||
<path
|
||||
v-if="connectionState.tempPath"
|
||||
:d="connectionState.tempPath"
|
||||
class="connection-temp"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- 节点渲染层 -->
|
||||
<div
|
||||
class="nodes-layer"
|
||||
:style="{ transform: 'translate(' + panX + 'px, ' + panY + 'px) scale(' + zoomLevel + ')' }"
|
||||
>
|
||||
<div
|
||||
v-for="node in treeNodes"
|
||||
:key="node.id"
|
||||
:data-node-id="node.id"
|
||||
class="tree-node"
|
||||
:class="[
|
||||
'node-' + node.type,
|
||||
{
|
||||
'node-selected': selectedNodeId === node.id,
|
||||
'node-error': node.hasError,
|
||||
'dragging': dragState.dragNodeId === node.id
|
||||
}
|
||||
]"
|
||||
:style="{
|
||||
left: node.x + 'px',
|
||||
top: node.y + 'px'
|
||||
}"
|
||||
@click="selectNode(node.id)"
|
||||
@mousedown="startNodeDrag($event, node)"
|
||||
>
|
||||
<div class="node-header">
|
||||
<span class="node-icon">{{ node.icon }}</span>
|
||||
<span class="node-title">{{ node.name }}</span>
|
||||
<button class="node-delete" @click.stop="deleteNode(node.id)">×</button>
|
||||
</div>
|
||||
<div class="node-body" v-if="node.description">
|
||||
<div class="node-description">{{ node.description }}</div>
|
||||
</div>
|
||||
<!-- 输入端口 - 执行流入口 -->
|
||||
<div
|
||||
v-if="node.canHaveParent"
|
||||
class="port port-input"
|
||||
:class="{
|
||||
'connecting': connectionState.isConnecting &&
|
||||
connectionState.startPortType === 'output' &&
|
||||
connectionState.startNodeId !== node.id,
|
||||
'drag-target': isValidConnectionTarget(node.id, 'input')
|
||||
}"
|
||||
@mousedown.stop="startConnection($event, node.id, 'input')"
|
||||
@mouseenter="onPortHover(node.id, 'input')"
|
||||
@mouseleave="onPortLeave()"
|
||||
title="执行流入口(可从此开始连接)"
|
||||
>
|
||||
<div class="port-inner"></div>
|
||||
</div>
|
||||
|
||||
<!-- 输出端口 - 执行流出口 -->
|
||||
<div
|
||||
v-if="node.canHaveChildren"
|
||||
class="port port-output"
|
||||
:class="{
|
||||
'connecting': connectionState.isConnecting &&
|
||||
connectionState.startPortType === 'input' &&
|
||||
connectionState.startNodeId !== node.id,
|
||||
'drag-target': isValidConnectionTarget(node.id, 'output')
|
||||
}"
|
||||
@mousedown.stop="startConnection($event, node.id, 'output')"
|
||||
@mouseenter="onPortHover(node.id, 'output')"
|
||||
@mouseleave="onPortLeave()"
|
||||
title="执行流出口(可从此开始连接)"
|
||||
>
|
||||
<div class="port-inner"></div>
|
||||
</div>
|
||||
<!-- 子节点指示器 -->
|
||||
<div v-if="node.children && node.children.length > 0" class="children-indicator">
|
||||
{{ node.children.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 网格背景 -->
|
||||
<div class="grid-background" :style="gridStyle()"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧属性面板 -->
|
||||
<div class="properties-panel">
|
||||
<div class="panel-header">
|
||||
<h3>⚙️ 属性面板</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNode()" class="node-properties">
|
||||
<div class="property-section">
|
||||
<h4>基本信息</h4>
|
||||
<div class="property-item">
|
||||
<label>节点名称:</label>
|
||||
<input
|
||||
type="text"
|
||||
:value="selectedNode().name"
|
||||
@input="updateNodeProperty('name', $event.target.value)"
|
||||
>
|
||||
</div>
|
||||
<div class="property-item">
|
||||
<label>描述:</label>
|
||||
<textarea
|
||||
:value="selectedNode().description"
|
||||
@input="updateNodeProperty('description', $event.target.value)"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="property-section" v-if="selectedNode().properties">
|
||||
<h4>节点属性</h4>
|
||||
<div
|
||||
v-for="(prop, key) in selectedNode().properties"
|
||||
:key="key"
|
||||
class="property-item"
|
||||
>
|
||||
<label>{{ prop.name }}:</label>
|
||||
<input
|
||||
v-if="prop.type === 'string'"
|
||||
type="text"
|
||||
:value="prop.value"
|
||||
@input="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
|
||||
>
|
||||
<input
|
||||
v-else-if="prop.type === 'number'"
|
||||
type="number"
|
||||
:value="prop.value"
|
||||
@input="updateNodeProperty('properties.' + key + '.value', parseFloat($event.target.value))"
|
||||
>
|
||||
<input
|
||||
v-else-if="prop.type === 'boolean'"
|
||||
type="checkbox"
|
||||
:checked="prop.value"
|
||||
@change="updateNodeProperty('properties.' + key + '.value', $event.target.checked)"
|
||||
>
|
||||
<textarea
|
||||
v-else-if="prop.type === 'code'"
|
||||
:value="prop.value"
|
||||
@input="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
|
||||
rows="6"
|
||||
class="code-input"
|
||||
placeholder="请输入代码..."
|
||||
></textarea>
|
||||
<select
|
||||
v-else-if="prop.type === 'select'"
|
||||
:value="prop.value"
|
||||
@change="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
|
||||
>
|
||||
<option v-for="option in prop.options" :key="option" :value="option">
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="prop.description" class="property-help">{{ prop.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="property-section">
|
||||
<h4>代码预览</h4>
|
||||
<pre class="code-preview">{{ generateNodeCode(selectedNode()) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-selection">
|
||||
<p>请选择一个节点查看属性</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 行为树结构面板 -->
|
||||
<div class="tree-structure-panel" v-if="rootNode()">
|
||||
<div class="panel-header">
|
||||
<h3>🌲 树结构</h3>
|
||||
</div>
|
||||
<div class="tree-view">
|
||||
<tree-node-item
|
||||
:node="rootNode()"
|
||||
:level="0"
|
||||
:get-node-by-id-local="getNodeByIdLocal"
|
||||
@node-select="selectNode"
|
||||
></tree-node-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证结果 -->
|
||||
<div v-if="!validationResult().isValid" class="validation-error">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<span>{{ validationResult().message }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 导出模态框 -->
|
||||
<div v-if="showExportModal" class="modal-overlay" @click="showExportModal = false">
|
||||
<div class="modal-content" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>导出代码</h3>
|
||||
<button @click="showExportModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="export-options">
|
||||
<label>
|
||||
<input type="radio" v-model="exportFormat" value="typescript"> TypeScript
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" v-model="exportFormat" value="json"> JSON
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
class="export-code"
|
||||
:value="exportedCode()"
|
||||
readonly
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button @click="copyToClipboard">复制到剪贴板</button>
|
||||
<button @click="saveToFile">保存到文件</button>
|
||||
<button @click="showExportModal = false">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="tree-node-item" :style="{ paddingLeft: (level * 16) + 'px' }">
|
||||
<div class="tree-node-line" @click="$emit('node-select', node.id)">
|
||||
<span class="tree-node-icon">{{ node.icon }}</span>
|
||||
<span class="tree-node-name">{{ node.name }}</span>
|
||||
<span class="tree-node-type">({{ node.type }})</span>
|
||||
</div>
|
||||
<tree-node-item
|
||||
v-for="childId in (node.children || [])"
|
||||
:key="childId"
|
||||
:node="getNodeByIdLocal(childId)"
|
||||
:level="level + 1"
|
||||
:get-node-by-id-local="getNodeByIdLocal"
|
||||
@node-select="$emit('node-select', $event)"
|
||||
v-if="getNodeByIdLocal(childId)"
|
||||
></tree-node-item>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div id="behavior-tree-app">
|
||||
<behavior-tree-editor></behavior-tree-editor>
|
||||
</div>
|
||||
9
extensions/cocos/cocos-ecs/package-lock.json
generated
9
extensions/cocos/cocos-ecs/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"version": "3.8.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esengine/ai": "^2.0.1",
|
||||
"@esengine/ecs-framework": "^2.1.19"
|
||||
}
|
||||
}
|
||||
|
||||
1
thirdparty/BehaviourTree-ai
vendored
Submodule
1
thirdparty/BehaviourTree-ai
vendored
Submodule
Submodule thirdparty/BehaviourTree-ai added at 73c1d77324
Reference in New Issue
Block a user