新增行为树编辑器

This commit is contained in:
YHH
2025-06-17 18:28:57 +08:00
parent 7808f64fe5
commit 577f1e429a
29 changed files with 5060 additions and 2 deletions

View File

@@ -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: "面板"
}
};

View File

@@ -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"
]
}
}
}

View File

@@ -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}`,
});
}
},
};
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'
}
];

View File

@@ -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();
}
},
});

View File

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

View File

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

View File

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

View File

@@ -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 : '未知错误'
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
<div id="behavior-tree-app">
<behavior-tree-editor></behavior-tree-editor>
</div>

View File

@@ -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",

View File

@@ -5,6 +5,7 @@
"version": "3.8.6"
},
"dependencies": {
"@esengine/ai": "^2.0.1",
"@esengine/ecs-framework": "^2.1.19"
}
}