拖拽逻辑更新

This commit is contained in:
YHH
2025-06-17 23:59:30 +08:00
parent 577f1e429a
commit 06ea01e928
24 changed files with 2127 additions and 1843 deletions

View File

@@ -0,0 +1,63 @@
{
"nodes": [
{
"id": "node_b0bpuk8ei",
"type": "Sequence",
"name": "序列器",
"icon": "→",
"description": "按顺序执行子节点,任一失败则整体失败",
"x": 207.39999389648438,
"y": 145.59999084472656,
"children": [
"node_pgmfxi7ho"
],
"properties": {
"abortType": {
"name": "中止类型",
"type": "select",
"value": "None",
"description": "决定节点在何种情况下会被中止",
"options": [
"None",
"LowerPriority",
"Self",
"Both"
],
"required": false
}
},
"canHaveChildren": true,
"canHaveParent": true,
"hasError": false
},
{
"id": "node_pgmfxi7ho",
"type": "Inverter",
"name": "反转器",
"icon": "⚡",
"description": "反转子节点的执行结果",
"x": 163.39999389648438,
"y": 436.59999084472656,
"children": [],
"properties": {},
"canHaveChildren": true,
"canHaveParent": true,
"hasError": false,
"parent": "node_b0bpuk8ei"
}
],
"connections": [
{
"id": "node_b0bpuk8ei-node_pgmfxi7ho",
"sourceId": "node_b0bpuk8ei",
"targetId": "node_pgmfxi7ho",
"path": "M 307.3999938964844 265.59999084472656 C 307.3999938964844 351.09999084472656 263.3999938964844 351.09999084472656 263.3999938964844 436.59999084472656",
"active": false
}
],
"metadata": {
"name": "untitled",
"created": "2025-06-17T14:52:33.885Z",
"version": "1.0"
}
}

View File

@@ -0,0 +1,11 @@
{
"ver": "2.0.1",
"importer": "json",
"imported": true,
"uuid": "cb66452d-5cad-46a9-96f9-b62831e0edc3",
"files": [
".json"
],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,14 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "da4522ce-bedb-42d5-8cba-63dcb4641265",
"files": [],
"subMetas": {},
"userData": {
"isBundle": true,
"bundleConfigID": "default",
"bundleName": "resources",
"priority": 8
}
}

View File

@@ -100,6 +100,20 @@
"message": "open-panel" "message": "open-panel"
} }
], ],
"asset-menu": [
{
"path": "i18n:menu.create/ECS Framework",
"label": "创建行为树文件",
"message": "create-behavior-tree-file",
"target": "folder"
},
{
"path": "i18n:menu.open",
"label": "用行为树编辑器打开",
"message": "open-behavior-tree-file",
"target": [".bt.json", ".json"]
}
],
"messages": { "messages": {
"open-panel": { "open-panel": {
"methods": [ "methods": [
@@ -175,6 +189,21 @@
"methods": [ "methods": [
"open-behavior-tree-docs" "open-behavior-tree-docs"
] ]
},
"create-behavior-tree-file": {
"methods": [
"create-behavior-tree-file"
]
},
"open-behavior-tree-file": {
"methods": [
"open-behavior-tree-file"
]
},
"create-behavior-tree-from-editor": {
"methods": [
"create-behavior-tree-from-editor"
]
} }
} }
} }

View File

@@ -531,6 +531,169 @@ export class AIExampleComponent extends Component {
}); });
} }
}, },
/**
* 创建行为树文件
*/
async 'create-behavior-tree-file'(assetInfo: any) {
console.log('Creating behavior tree file in folder:', assetInfo?.path);
try {
// 获取项目assets目录
const projectPath = Editor.Project.path;
const assetsPath = path.join(projectPath, 'assets');
// 生成唯一文件名
let fileName = 'NewBehaviorTree';
let counter = 1;
let filePath = path.join(assetsPath, `${fileName}.bt.json`);
while (fs.existsSync(filePath)) {
fileName = `NewBehaviorTree_${counter}`;
filePath = path.join(assetsPath, `${fileName}.bt.json`);
counter++;
}
// 创建默认的行为树配置
const defaultConfig = {
version: "1.0.0",
type: "behavior-tree",
metadata: {
createdAt: new Date().toISOString(),
nodeCount: 1
},
tree: {
id: "root",
type: "sequence",
namespace: "behaviourTree/composites",
properties: {},
children: []
}
};
// 写入文件
await fsExtra.writeFile(filePath, JSON.stringify(defaultConfig, null, 2));
// 刷新资源管理器
await Editor.Message.request('asset-db', 'refresh-asset', 'db://assets');
console.log(`Behavior tree file created: ${filePath}`);
Editor.Dialog.info('创建成功', {
detail: `行为树文件 "${fileName}.bt.json" 已创建完成!\n\n文件位置assets/${fileName}.bt.json\n\n您可以右键点击文件选择"用行为树编辑器打开"来编辑它。`,
});
} catch (error) {
console.error('Failed to create behavior tree file:', error);
Editor.Dialog.error('创建失败', {
detail: `创建行为树文件失败:\n\n${error instanceof Error ? error.message : String(error)}`,
});
}
},
/**
* 用行为树编辑器打开文件
*/
async 'open-behavior-tree-file'(assetInfo: any) {
console.log('Opening behavior tree file:', assetInfo);
try {
// 直接从assetInfo获取文件系统路径
const assetPath = assetInfo?.path;
if (!assetPath) {
throw new Error('无效的文件路径');
}
// 转换为文件系统路径
const projectPath = Editor.Project.path;
const relativePath = assetPath.replace('db://assets/', '');
const fsPath = path.join(projectPath, 'assets', relativePath);
console.log('File system path:', fsPath);
// 检查文件是否存在
if (!fs.existsSync(fsPath)) {
throw new Error('文件不存在');
}
// 检查文件是否为JSON格式
let fileContent: any;
try {
const content = await fsExtra.readFile(fsPath, 'utf8');
fileContent = JSON.parse(content);
} catch (parseError) {
throw new Error('文件不是有效的JSON格式');
}
// 验证是否为行为树文件
if (fileContent.type !== 'behavior-tree' && !fileContent.tree) {
const confirm = await new Promise<boolean>((resolve) => {
Editor.Dialog.warn('文件格式提醒', {
detail: '此文件可能不是标准的行为树配置文件,仍要打开吗?',
buttons: ['打开', '取消'],
}).then((result: any) => {
resolve(result.response === 0);
});
});
if (!confirm) {
return;
}
}
// 打开行为树编辑器面板
Editor.Panel.open('cocos-ecs-extension.behavior-tree');
console.log(`Behavior tree file opened in editor: ${fsPath}`);
} catch (error) {
console.error('Failed to open behavior tree file:', error);
Editor.Dialog.error('打开失败', {
detail: `打开行为树文件失败:\n\n${error instanceof Error ? error.message : String(error)}`,
});
}
},
/**
* 从编辑器创建行为树文件
*/
async 'create-behavior-tree-from-editor'(data: { fileName: string, content: string }) {
console.log('Creating behavior tree file from editor:', data.fileName);
try {
const projectPath = Editor.Project.path;
const assetsPath = path.join(projectPath, 'assets');
// 确保文件名唯一
let fileName = data.fileName;
let counter = 1;
let filePath = path.join(assetsPath, `${fileName}.bt.json`);
while (fs.existsSync(filePath)) {
fileName = `${data.fileName}_${counter}`;
filePath = path.join(assetsPath, `${fileName}.bt.json`);
counter++;
}
// 写入文件
await fsExtra.writeFile(filePath, data.content);
// 刷新资源管理器
await Editor.Message.request('asset-db', 'refresh-asset', 'db://assets');
console.log(`Behavior tree file created from editor: ${filePath}`);
Editor.Dialog.info('保存成功', {
detail: `行为树文件 "${fileName}.bt.json" 已保存到 assets 目录中!`,
});
} catch (error) {
console.error('Failed to create behavior tree file from editor:', error);
Editor.Dialog.error('保存失败', {
detail: `保存行为树文件失败:\n\n${error instanceof Error ? error.message : String(error)}`,
});
}
},
}; };
/** /**

View File

@@ -44,7 +44,7 @@ export function useAppState() {
// UI状态 // UI状态
const showExportModal = ref(false); const showExportModal = ref(false);
const exportFormat = ref('typescript'); const exportFormat = ref('json');
// 工具函数 // 工具函数
const getNodeByIdLocal = (id: string): TreeNode | undefined => { const getNodeByIdLocal = (id: string): TreeNode | undefined => {
@@ -62,6 +62,17 @@ export function useAppState() {
tempConnection.value.path = ''; tempConnection.value.path = '';
}; };
const updateCanvasSize = () => {
const canvasArea = document.querySelector('.canvas-area') as HTMLElement;
if (canvasArea) {
const rect = canvasArea.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
canvasWidth.value = Math.max(rect.width, 800);
canvasHeight.value = Math.max(rect.height, 600);
}
}
};
return { return {
// 安装状态 // 安装状态
checkingStatus, checkingStatus,
@@ -94,6 +105,7 @@ export function useAppState() {
// 工具函数 // 工具函数
getNodeByIdLocal, getNodeByIdLocal,
selectNode, selectNode,
newBehaviorTree newBehaviorTree,
updateCanvasSize
}; };
} }

View File

@@ -5,6 +5,9 @@ import { useNodeOperations } from './useNodeOperations';
import { useCodeGeneration } from './useCodeGeneration'; import { useCodeGeneration } from './useCodeGeneration';
import { useInstallation } from './useInstallation'; import { useInstallation } from './useInstallation';
import { useFileOperations } from './useFileOperations'; import { useFileOperations } from './useFileOperations';
import { useConnectionManager } from './useConnectionManager';
import { useCanvasManager } from './useCanvasManager';
import { useNodeDisplay } from './useNodeDisplay';
/** /**
* 主要的行为树编辑器组合功能 * 主要的行为树编辑器组合功能
@@ -16,6 +19,23 @@ export function useBehaviorTreeEditor() {
// 获取其他组合功能 // 获取其他组合功能
const appState = useAppState(); const appState = useAppState();
// 临时根节点获取函数
const getRootNode = () => {
return appState.treeNodes.value.find(node =>
!appState.treeNodes.value.some(otherNode =>
otherNode.children?.includes(node.id)
)
) || null;
};
const codeGen = useCodeGeneration(
appState.treeNodes,
appState.nodeTemplates,
appState.getNodeByIdLocal,
getRootNode
);
const computedProps = useComputedProperties( const computedProps = useComputedProperties(
appState.nodeTemplates, appState.nodeTemplates,
appState.nodeSearchText, appState.nodeSearchText,
@@ -29,8 +49,13 @@ export function useBehaviorTreeEditor() {
appState.panX, appState.panX,
appState.panY, appState.panY,
appState.zoomLevel, appState.zoomLevel,
appState.getNodeByIdLocal appState.getNodeByIdLocal,
{
generateConfigJSON: codeGen.generateConfigJSON,
generateTypeScriptCode: codeGen.generateTypeScriptCode
}
); );
const nodeOps = useNodeOperations( const nodeOps = useNodeOperations(
appState.treeNodes, appState.treeNodes,
appState.selectedNodeId, appState.selectedNodeId,
@@ -38,629 +63,153 @@ export function useBehaviorTreeEditor() {
appState.panX, appState.panX,
appState.panY, appState.panY,
appState.zoomLevel, appState.zoomLevel,
appState.getNodeByIdLocal
);
const codeGen = useCodeGeneration(
appState.treeNodes,
appState.nodeTemplates,
appState.getNodeByIdLocal, appState.getNodeByIdLocal,
() => computedProps.rootNode() || null () => connectionManager.updateConnections()
); );
const installation = useInstallation( const installation = useInstallation(
appState.checkingStatus, appState.checkingStatus,
appState.isInstalled, appState.isInstalled,
appState.version, appState.version,
appState.isInstalling appState.isInstalling
); );
const fileOps = useFileOperations( const fileOps = useFileOperations(
appState.treeNodes, appState.treeNodes,
appState.selectedNodeId, appState.selectedNodeId,
appState.connections, appState.connections,
appState.tempConnection, appState.tempConnection,
appState.showExportModal appState.showExportModal,
codeGen,
() => connectionManager.updateConnections()
); );
// 连线状态管理 - 使用reactive代替复杂的状态管理
const connectionState = reactive({ const connectionState = reactive({
isConnecting: false, isConnecting: false,
startNodeId: null as string | null, startNodeId: null as string | null,
startPortType: null as 'input' | 'output' | null, startPortType: null as 'input' | 'output' | null,
tempPath: '', tempPath: '',
currentMousePos: { x: 0, y: 0 }, currentMousePos: null as { x: number, y: number } | null,
hoveredPort: null as { nodeId: string, portType: string } | null startPortPos: null as { x: number, y: number } | null,
hoveredPort: null as { nodeId: string, portType: 'input' | 'output' } | null
}); });
// 连线方法 const connectionManager = useConnectionManager(
const startConnection = (event: MouseEvent, nodeId: string, portType: 'input' | 'output') => { appState.treeNodes,
event.stopPropagation(); appState.connections,
event.preventDefault(); connectionState,
canvasAreaRef,
svgRef,
appState.panX,
appState.panY,
appState.zoomLevel
);
const canvasManager = useCanvasManager(
appState.panX,
appState.panY,
appState.zoomLevel,
appState.treeNodes,
appState.selectedNodeId,
canvasAreaRef,
connectionManager.updateConnections
);
const nodeDisplay = useNodeDisplay();
const dragState = reactive({
isDragging: false,
dragNode: null as any,
dragElement: null as HTMLElement | null,
dragOffset: { x: 0, y: 0 },
startPosition: { x: 0, y: 0 },
updateCounter: 0
});
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) => { const startNodeDrag = (event: MouseEvent, node: any) => {
// 阻止默认行为
event.preventDefault();
event.stopPropagation(); event.stopPropagation();
event.preventDefault();
// 设置拖拽状态 dragState.isDragging = true;
appState.dragState.value.isDraggingNode = true; dragState.dragNode = node;
appState.dragState.value.dragNodeId = node.id; dragState.startPosition = { x: event.clientX, y: event.clientY };
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类提升性能 dragState.dragElement = document.querySelector(`[data-node-id="${node.id}"]`) as HTMLElement;
const nodeElement = event.currentTarget as HTMLElement; if (dragState.dragElement) {
nodeElement.classList.add('dragging'); dragState.dragElement.classList.add('dragging');
}
dragState.dragOffset = {
x: node.x,
y: node.y
};
// 添加全局事件监听移除passive优化确保实时性
document.addEventListener('mousemove', onNodeDrag); document.addEventListener('mousemove', onNodeDrag);
document.addEventListener('mouseup', onNodeDragEnd); document.addEventListener('mouseup', onNodeDragEnd);
}; };
const onNodeDrag = (event: MouseEvent) => { const onNodeDrag = (event: MouseEvent) => {
if (!appState.dragState.value.isDraggingNode || !appState.dragState.value.dragNodeId) return; if (!dragState.isDragging || !dragState.dragNode) return;
const deltaX = (event.clientX - appState.dragState.value.dragStartX) / appState.zoomLevel.value; const deltaX = (event.clientX - dragState.startPosition.x) / appState.zoomLevel.value;
const deltaY = (event.clientY - appState.dragState.value.dragStartY) / appState.zoomLevel.value; const deltaY = (event.clientY - dragState.startPosition.y) / appState.zoomLevel.value;
const node = appState.treeNodes.value.find((n: any) => n.id === appState.dragState.value.dragNodeId); dragState.dragNode.x = dragState.dragOffset.x + deltaX;
if (node) { dragState.dragNode.y = dragState.dragOffset.y + deltaY;
node.x = appState.dragState.value.dragNodeStartX + deltaX;
node.y = appState.dragState.value.dragNodeStartY + deltaY;
// 立即更新连接线,无防抖 connectionManager.updateConnections();
updateConnections();
}
}; };
const onNodeDragEnd = (event: MouseEvent) => { const onNodeDragEnd = (event: MouseEvent) => {
if (appState.dragState.value.isDraggingNode) { if (!dragState.isDragging) return;
// 移除dragging类
const draggingNodes = document.querySelectorAll('.tree-node.dragging');
draggingNodes.forEach(node => node.classList.remove('dragging'));
appState.dragState.value.isDraggingNode = false; if (dragState.dragElement) {
appState.dragState.value.dragNodeId = null; dragState.dragElement.classList.remove('dragging');
// 最终更新连接线
updateConnections();
// 移除全局事件监听
document.removeEventListener('mousemove', onNodeDrag);
document.removeEventListener('mouseup', onNodeDragEnd);
} }
dragState.isDragging = false;
dragState.dragNode = null;
dragState.dragElement = null;
document.removeEventListener('mousemove', onNodeDrag);
document.removeEventListener('mouseup', onNodeDragEnd);
connectionManager.updateConnections();
dragState.updateCounter = 0;
}; };
// 画布操作功能
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 = () => { const handleInstall = () => {
// 这里应该调用installation中的安装方法 installation.handleInstall();
}; };
// 生命周期管理 // 组件挂载时初始化连接
onMounted(() => { onMounted(() => {
// 初始化连接线 // 延迟一下确保 DOM 已经渲染
nextTick(() => { nextTick(() => {
updateConnections(); connectionManager.updateConnections();
}); });
}); });
onUnmounted(() => { onUnmounted(() => {
// 清理事件监听器
cancelConnection();
document.removeEventListener('mousemove', onNodeDrag); document.removeEventListener('mousemove', onNodeDrag);
document.removeEventListener('mouseup', onNodeDragEnd); 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 { return {
// DOM refs
canvasAreaRef, canvasAreaRef,
svgRef, svgRef,
// 状态
...appState, ...appState,
connectionState, ...computedProps,
// 计算属性 - 显式导出,避免命名冲突
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, ...nodeOps,
...fileOps,
...codeGen, ...codeGen,
...installation, ...installation,
...fileOps, handleInstall,
connectionState,
...connectionManager,
...canvasManager,
...nodeDisplay,
startNodeDrag,
dragState
}; };
} }

View File

@@ -0,0 +1,203 @@
import { Ref, ref } from 'vue';
import { TreeNode, DragState } from '../types';
/**
* 画布管理功能
*/
export function useCanvasManager(
panX: Ref<number>,
panY: Ref<number>,
zoomLevel: Ref<number>,
treeNodes: Ref<TreeNode[]>,
selectedNodeId: Ref<string | null>,
canvasAreaRef: Ref<HTMLElement | null>,
updateConnections: () => void
) {
// 画布尺寸 - 使用默认值或从DOM获取
const canvasWidth = ref(800);
const canvasHeight = ref(600);
// 拖拽状态
const dragState = ref<DragState>({
isDraggingCanvas: false,
isDraggingNode: false,
dragNodeId: null,
dragStartX: 0,
dragStartY: 0,
dragNodeStartX: 0,
dragNodeStartY: 0,
isConnecting: false,
connectionStart: null,
connectionEnd: { x: 0, y: 0 }
});
// 如果有canvas引用更新尺寸
if (canvasAreaRef.value) {
const rect = canvasAreaRef.value.getBoundingClientRect();
canvasWidth.value = rect.width;
canvasHeight.value = rect.height;
}
// 画布操作功能
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, zoomLevel.value + delta));
zoomLevel.value = newZoom;
};
const onCanvasMouseDown = (event: MouseEvent) => {
// 只在空白区域开始画布拖拽
if (event.target === event.currentTarget) {
dragState.value.isDraggingCanvas = true;
dragState.value.dragStartX = event.clientX;
dragState.value.dragStartY = event.clientY;
document.addEventListener('mousemove', onCanvasMouseMove);
document.addEventListener('mouseup', onCanvasMouseUp);
}
};
const onCanvasMouseMove = (event: MouseEvent) => {
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;
}
};
const onCanvasMouseUp = (event: MouseEvent) => {
if (dragState.value.isDraggingCanvas) {
dragState.value.isDraggingCanvas = false;
document.removeEventListener('mousemove', onCanvasMouseMove);
document.removeEventListener('mouseup', onCanvasMouseUp);
}
};
// 缩放控制
const zoomIn = () => {
zoomLevel.value = Math.min(3, zoomLevel.value + 0.1);
};
const zoomOut = () => {
zoomLevel.value = Math.max(0.1, zoomLevel.value - 0.1);
};
const resetZoom = () => {
zoomLevel.value = 1;
};
const centerView = () => {
if (treeNodes.value.length === 0) {
panX.value = 0;
panY.value = 0;
return;
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
treeNodes.value.forEach(node => {
// 尝试从DOM获取实际节点尺寸否则使用默认值
const nodeElement = document.querySelector(`[data-node-id="${node.id}"]`);
let nodeWidth = 150;
let nodeHeight = 80; // 使用基础高度
if (nodeElement) {
const rect = nodeElement.getBoundingClientRect();
nodeWidth = rect.width / zoomLevel.value;
nodeHeight = rect.height / zoomLevel.value;
}
minX = Math.min(minX, node.x);
minY = Math.min(minY, node.y);
maxX = Math.max(maxX, node.x + nodeWidth);
maxY = Math.max(maxY, node.y + nodeHeight);
});
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 gridStyle = () => {
const gridSize = 20 * zoomLevel.value;
return {
backgroundSize: `${gridSize}px ${gridSize}px`,
backgroundPosition: `${panX.value % gridSize}px ${panY.value % gridSize}px`
};
};
// 节点拖拽功能
const startNodeDrag = (event: MouseEvent, node: any) => {
event.preventDefault();
event.stopPropagation();
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 = event.currentTarget as HTMLElement;
nodeElement.classList.add('dragging');
document.addEventListener('mousemove', onNodeDrag);
document.addEventListener('mouseup', onNodeDragEnd);
};
const onNodeDrag = (event: MouseEvent) => {
if (!dragState.value.isDraggingNode || !dragState.value.dragNodeId) return;
const deltaX = (event.clientX - dragState.value.dragStartX) / zoomLevel.value;
const deltaY = (event.clientY - dragState.value.dragStartY) / zoomLevel.value;
const node = treeNodes.value.find(n => n.id === dragState.value.dragNodeId);
if (node) {
node.x = dragState.value.dragNodeStartX + deltaX;
node.y = dragState.value.dragNodeStartY + deltaY;
updateConnections();
}
};
const onNodeDragEnd = (event: MouseEvent) => {
if (dragState.value.isDraggingNode) {
const draggingNodes = document.querySelectorAll('.tree-node.dragging');
draggingNodes.forEach(node => node.classList.remove('dragging'));
dragState.value.isDraggingNode = false;
dragState.value.dragNodeId = null;
updateConnections();
document.removeEventListener('mousemove', onNodeDrag);
document.removeEventListener('mouseup', onNodeDragEnd);
}
};
return {
onCanvasWheel,
onCanvasMouseDown,
onCanvasMouseMove,
onCanvasMouseUp,
zoomIn,
zoomOut,
resetZoom,
centerView,
gridStyle,
startNodeDrag
};
}

View File

@@ -12,97 +12,329 @@ export function useCodeGeneration(
rootNode: () => TreeNode | null rootNode: () => TreeNode | null
) { ) {
// TypeScript代码生成 // 生成行为树配置JSON
const generateTypeScriptCode = (): string => { const generateBehaviorTreeConfig = () => {
const imports = getRequiredImports();
const root = rootNode(); const root = rootNode();
if (!root) { if (!root) {
return null;
}
return {
version: "1.0.0",
type: "behavior-tree",
metadata: {
createdAt: new Date().toISOString(),
hasECSNodes: hasECSNodes(),
nodeCount: treeNodes.value.length
},
tree: generateNodeConfig(root)
};
};
// 生成可读的配置JSON字符串
const generateConfigJSON = (): string => {
const config = generateBehaviorTreeConfig();
if (!config) {
return '// 请先添加根节点'; return '// 请先添加根节点';
} }
const importsCode = imports.map(imp => `import { ${imp} } from '@esengine/ai';`).join('\n'); return JSON.stringify(config, null, 2);
const treeCode = generateNodeCode(root);
return `${importsCode}
// 自动生成的行为树代码
export function createBehaviorTree() {
return ${treeCode};
}`;
}; };
const getRequiredImports = (): string[] => { // 生成TypeScript构建代码用于运行时从配置创建行为树
const imports = new Set<string>(); const generateTypeScriptCode = (): string => {
const config = generateBehaviorTreeConfig();
if (!config) {
return '// 请先添加根节点';
}
const { behaviorTreeImports, ecsImports } = getRequiredImports();
let importsCode = '';
if (behaviorTreeImports.length > 0) {
importsCode += `import { ${behaviorTreeImports.join(', ')}, BehaviorTreeBuilder } from '@esengine/ai';\n`;
}
if (ecsImports.length > 0) {
importsCode += `import { ${ecsImports.join(', ')} } from '@esengine/ecs-framework';\n`;
}
const contextType = hasECSNodes() ? 'Entity' : 'any';
const configString = JSON.stringify(config, null, 4);
return `${importsCode}
// 行为树配置
const behaviorTreeConfig = ${configString};
// 从配置创建行为树
export function createBehaviorTree<T extends ${contextType}>(context?: T): BehaviorTree<T> {
return BehaviorTreeBuilder.fromConfig<T>(behaviorTreeConfig, context);
}
// 直接导出配置(用于序列化保存)
export const config = behaviorTreeConfig;`;
};
const getRequiredImports = (): { behaviorTreeImports: string[], ecsImports: string[] } => {
const behaviorTreeImports = new Set<string>();
const ecsImports = new Set<string>();
// 总是需要这些基础类
behaviorTreeImports.add('BehaviorTree');
behaviorTreeImports.add('TaskStatus');
treeNodes.value.forEach(node => { treeNodes.value.forEach(node => {
const template = nodeTemplates.value.find(t => t.className === node.type || t.type === node.type); const template = nodeTemplates.value.find(t => t.className === node.type || t.type === node.type);
if (template?.className) { if (template?.className) {
imports.add(template.className); if (template.namespace?.includes('ecs-integration')) {
behaviorTreeImports.add(template.className);
ecsImports.add('Entity');
ecsImports.add('Component');
} else {
behaviorTreeImports.add(template.className);
}
} }
}); });
return Array.from(imports); return {
behaviorTreeImports: Array.from(behaviorTreeImports),
ecsImports: Array.from(ecsImports)
};
};
const hasECSNodes = (): boolean => {
return treeNodes.value.some(node => {
const template = nodeTemplates.value.find(t => t.className === node.type || t.type === node.type);
return template?.namespace?.includes('ecs-integration');
});
};
// 生成节点配置对象
const generateNodeConfig = (node: TreeNode): any => {
const template = nodeTemplates.value.find(t => t.className === node.type || t.type === node.type);
if (!template || !template.className) {
return {
type: node.type,
error: "未知节点类型"
};
}
const nodeConfig: any = {
id: node.id,
type: template.className,
namespace: template.namespace || 'behaviourTree',
properties: {}
};
// 处理节点属性
if (node.properties) {
Object.entries(node.properties).forEach(([key, prop]) => {
if (prop.value !== undefined && prop.value !== '') {
nodeConfig.properties[key] = {
type: prop.type,
value: prop.value
};
}
});
}
// 处理子节点
if (node.children && node.children.length > 0) {
nodeConfig.children = node.children
.map(childId => getNodeByIdLocal(childId))
.filter(Boolean)
.map(child => generateNodeConfig(child!));
}
return nodeConfig;
}; };
const generateNodeCode = (node: TreeNode, indent: number = 0): string => { const generateNodeCode = (node: TreeNode, indent: number = 0): string => {
const spaces = ' '.repeat(indent); const spaces = ' '.repeat(indent);
const template = nodeTemplates.value.find(t => t.className === node.type); const template = nodeTemplates.value.find(t => t.className === node.type || t.type === node.type);
if (!template) { if (!template || !template.className) {
return `${spaces}// 未知节点类型: ${node.type}`; return `${spaces}// 未知节点类型: ${node.type}`;
} }
let code = `${spaces}new ${template.className}(`; let code = `${spaces}new ${template.className}(`;
// 构造函数参数
const params: string[] = []; const params: string[] = [];
// 处理属性 // 处理特定节点的构造函数参数
if (node.properties && Object.keys(node.properties).length > 0) { if (template.namespace?.includes('ecs-integration')) {
const propsCode: string[] = []; // ECS节点的特殊处理
switch (template.className) {
Object.entries(node.properties).forEach(([key, prop]) => { case 'HasComponentCondition':
if (prop.type === 'code' && prop.value) { case 'AddComponentAction':
propsCode.push(`${key}: ${prop.value}`); case 'RemoveComponentAction':
} else if (prop.type === 'string' && prop.value !== undefined) { case 'ModifyComponentAction':
propsCode.push(`${key}: "${prop.value}"`); if (node.properties?.componentType?.value) {
} else if (prop.type === 'number' && prop.value !== undefined) { params.push(node.properties.componentType.value);
propsCode.push(`${key}: ${prop.value}`); }
} else if (prop.type === 'boolean' && prop.value !== undefined) { if (template.className === 'AddComponentAction' && node.properties?.componentFactory?.value) {
propsCode.push(`${key}: ${prop.value}`); params.push(node.properties.componentFactory.value);
} else if (prop.type === 'select' && prop.value !== undefined) { }
propsCode.push(`${key}: "${prop.value}"`); if (template.className === 'ModifyComponentAction' && node.properties?.modifierCode?.value) {
} params.push(node.properties.modifierCode.value);
}); }
break;
if (propsCode.length > 0) { case 'HasTagCondition':
params.push(`{\n${spaces} ${propsCode.join(',\n' + spaces + ' ')}\n${spaces}}`); if (node.properties?.tag?.value !== undefined) {
params.push(node.properties.tag.value.toString());
}
break;
case 'IsActiveCondition':
if (node.properties?.checkHierarchy?.value !== undefined) {
params.push(node.properties.checkHierarchy.value.toString());
}
break;
case 'WaitTimeAction':
if (node.properties?.waitTime?.value !== undefined) {
params.push(node.properties.waitTime.value.toString());
}
break;
}
} else {
// 普通行为树节点的处理
switch (template.className) {
case 'ExecuteAction':
case 'ExecuteActionConditional':
if (node.properties?.actionCode?.value || node.properties?.conditionCode?.value) {
const code = node.properties.actionCode?.value || node.properties.conditionCode?.value;
params.push(code);
if (node.properties?.actionName?.value) {
params.push(`{ name: "${node.properties.actionName.value}" }`);
}
}
break;
case 'WaitAction':
if (node.properties?.waitTime?.value !== undefined) {
params.push(node.properties.waitTime.value.toString());
}
break;
case 'LogAction':
if (node.properties?.message?.value) {
params.push(`"${node.properties.message.value}"`);
}
break;
case 'Repeater':
if (node.properties?.repeatCount?.value !== undefined) {
params.push(node.properties.repeatCount.value.toString());
}
break;
case 'Sequence':
case 'Selector':
if (node.properties?.abortType?.value && node.properties.abortType.value !== 'None') {
params.push(`AbortTypes.${node.properties.abortType.value}`);
}
break;
} }
} }
code += params.join(', '); code += params.join(', ');
code += ')';
// 子节点 // 处理子节点(对于复合节点和装饰器)
if (node.children && node.children.length > 0) { if (template.canHaveChildren && node.children && node.children.length > 0) {
const children = node.children const children = node.children
.map(childId => getNodeByIdLocal(childId)) .map(childId => getNodeByIdLocal(childId))
.filter(Boolean) .filter(Boolean)
.map(child => generateNodeCode(child!, indent + 1)); .map(child => generateNodeCode(child!, indent + 1));
if (children.length > 0) { if (children.length > 0) {
if (params.length > 0) code += ', '; const className = template.className; // 保存到局部变量
code += '[\n' + children.join(',\n') + '\n' + spaces + ']'; if (template.category === 'decorator') {
// 装饰器只有一个子节点
code = code.slice(0, -1); // 移除最后的 ')'
const varName = className.toLowerCase();
code += `;\n${spaces}${varName}.child = ${children[0].trim()};\n${spaces}return ${varName}`;
} else if (template.category === 'composite') {
// 复合节点需要添加子节点
code = code.slice(0, -1); // 移除最后的 ')'
code += `;\n`;
children.forEach(child => {
code += `${spaces}${className.toLowerCase()}.addChild(${child.trim()});\n`;
});
code += `${spaces}return ${className.toLowerCase()}`;
}
} }
} }
code += ')';
return code; return code;
}; };
// 从配置创建行为树节点
const createTreeFromConfig = (config: any): TreeNode[] => {
if (!config || !config.tree) {
return [];
}
const nodes: TreeNode[] = [];
const processNode = (nodeConfig: any, parent?: TreeNode): TreeNode => {
const template = nodeTemplates.value.find(t => t.className === nodeConfig.type);
if (!template) {
throw new Error(`未知节点类型: ${nodeConfig.type}`);
}
const node: TreeNode = {
id: nodeConfig.id || generateNodeId(),
type: template.type,
name: template.name,
icon: template.icon,
description: template.description,
canHaveChildren: template.canHaveChildren,
canHaveParent: template.canHaveParent,
x: 400, // 默认在画布中心
y: 100, // 从顶部开始
properties: {},
children: [],
parent: parent?.id // 设置父节点ID
};
// 恢复属性
if (nodeConfig.properties) {
Object.entries(nodeConfig.properties).forEach(([key, propConfig]: [string, any]) => {
if (template.properties?.[key]) {
node.properties![key] = {
...template.properties[key],
value: propConfig.value
};
}
});
}
nodes.push(node);
// 处理子节点
if (nodeConfig.children && Array.isArray(nodeConfig.children)) {
nodeConfig.children.forEach((childConfig: any) => {
const childNode = processNode(childConfig, node);
node.children!.push(childNode.id);
});
}
return node;
};
processNode(config.tree);
return nodes;
};
// 生成唯一节点ID
const generateNodeId = (): string => {
return 'node_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
};
return { return {
generateBehaviorTreeConfig,
generateConfigJSON,
generateTypeScriptCode, generateTypeScriptCode,
generateNodeCode, generateNodeCode,
generateNodeConfig,
createTreeFromConfig,
getRequiredImports getRequiredImports
}; };
} }

View File

@@ -1,9 +1,8 @@
import { Ref } from 'vue'; import { Ref, computed } from 'vue';
import { TreeNode } from '../types'; import { TreeNode } from '../types';
import { NodeTemplate } from '../data/nodeTemplates'; import { NodeTemplate } from '../data/nodeTemplates';
import { getRootNode } from '../utils/nodeUtils'; import { getRootNode } from '../utils/nodeUtils';
import { getInstallStatusText, getInstallStatusClass } from '../utils/installUtils'; import { getInstallStatusText, getInstallStatusClass } from '../utils/installUtils';
import { generateCode } from '../utils/codeGenerator';
import { getGridStyle } from '../utils/canvasUtils'; import { getGridStyle } from '../utils/canvasUtils';
/** /**
@@ -22,7 +21,11 @@ export function useComputedProperties(
panX: Ref<number>, panX: Ref<number>,
panY: Ref<number>, panY: Ref<number>,
zoomLevel: Ref<number>, zoomLevel: Ref<number>,
getNodeByIdLocal: (id: string) => TreeNode | undefined getNodeByIdLocal: (id: string) => TreeNode | undefined,
codeGeneration?: {
generateConfigJSON: () => string;
generateTypeScriptCode: () => string;
}
) { ) {
// 过滤节点 // 过滤节点
const filteredCompositeNodes = () => { const filteredCompositeNodes = () => {
@@ -60,10 +63,14 @@ export function useComputedProperties(
); );
}; };
// 选中的节点 // 选中的节点 - 使用computed确保响应式更新
const selectedNode = () => { const selectedNode = computed(() => {
return selectedNodeId.value ? getNodeByIdLocal(selectedNodeId.value) : null; if (!selectedNodeId.value) return null;
};
// 直接从treeNodes数组中查找确保获取最新的节点状态
const node = treeNodes.value.find(n => n.id === selectedNodeId.value);
return node || null;
});
// 根节点 // 根节点
const rootNode = () => { const rootNode = () => {
@@ -98,8 +105,16 @@ export function useComputedProperties(
// 导出代码 // 导出代码
const exportedCode = () => { const exportedCode = () => {
if (!codeGeneration) {
return '// 代码生成器未初始化';
}
try { try {
return generateCode(treeNodes.value, exportFormat.value); if (exportFormat.value === 'json') {
return codeGeneration.generateConfigJSON();
} else {
return codeGeneration.generateTypeScriptCode();
}
} catch (error) { } catch (error) {
return `// 代码生成失败: ${error}`; return `// 代码生成失败: ${error}`;
} }

View File

@@ -0,0 +1,485 @@
import { Ref } from 'vue';
import { TreeNode, Connection, ConnectionState } from '../types';
/**
* 连接线管理功能
*/
export function useConnectionManager(
treeNodes: Ref<TreeNode[]>,
connections: Ref<Connection[]>,
connectionState: ConnectionState,
canvasAreaRef: Ref<HTMLElement | null>,
svgRef: Ref<SVGElement | null>,
panX: Ref<number>,
panY: Ref<number>,
zoomLevel: Ref<number>
) {
const getPortPosition = (nodeId: string, portType: 'input' | 'output') => {
const node = treeNodes.value.find(n => n.id === nodeId);
if (!node) return null;
const canvasArea = canvasAreaRef.value;
if (!canvasArea) {
return getCalculatedPortPosition(node, portType);
}
const selectors = [
`[data-node-id="${nodeId}"]`,
`.tree-node[data-node-id="${nodeId}"]`,
`div[data-node-id="${nodeId}"]`
];
let nodeElement: HTMLElement | null = null;
for (const selector of selectors) {
try {
const doc = canvasArea.ownerDocument || document;
const foundElement = doc.querySelector(selector);
if (foundElement && canvasArea.contains(foundElement)) {
nodeElement = foundElement as HTMLElement;
break;
}
} catch (error) {
continue;
}
}
if (!nodeElement) {
try {
const allTreeNodes = canvasArea.querySelectorAll('.tree-node');
for (let i = 0; i < allTreeNodes.length; i++) {
const el = allTreeNodes[i] as HTMLElement;
const dataNodeId = el.getAttribute('data-node-id');
if (dataNodeId === nodeId) {
nodeElement = el;
break;
}
}
} catch (error) {
// Fallback to calculated position
}
}
if (!nodeElement) {
return getCalculatedPortPosition(node, portType);
}
const portSelectors = [
`.port.port-${portType}`,
`.port-${portType}`,
`.port.${portType}`,
`.${portType}-port`
];
let portElement: HTMLElement | null = null;
for (const portSelector of portSelectors) {
try {
portElement = nodeElement.querySelector(portSelector) as HTMLElement;
if (portElement) {
break;
}
} catch (error) {
continue;
}
}
if (!portElement) {
return getNodeEdgePortPosition(nodeElement, node, portType);
}
const portRect = portElement.getBoundingClientRect();
const canvasRect = canvasAreaRef.value?.getBoundingClientRect();
if (!canvasRect) {
return getCalculatedPortPosition(node, portType);
}
const relativeX = portRect.left + portRect.width / 2 - canvasRect.left;
const relativeY = portRect.top + portRect.height / 2 - canvasRect.top;
const svgX = (relativeX - panX.value) / zoomLevel.value;
const svgY = (relativeY - panY.value) / zoomLevel.value;
return { x: svgX, y: svgY };
};
const getCalculatedPortPosition = (node: any, portType: 'input' | 'output') => {
let nodeWidth = 150;
let nodeHeight = 80;
if (node.properties) {
const propertyCount = Object.keys(node.properties).length;
if (propertyCount > 0) {
nodeHeight += propertyCount * 20 + 20;
nodeWidth = Math.max(150, nodeWidth + 50);
}
}
const portX = node.x + nodeWidth / 2;
const portY = portType === 'input'
? node.y - 8
: node.y + nodeHeight + 8;
return { x: portX, y: portY };
};
const getNodeEdgePortPosition = (nodeElement: HTMLElement, node: any, portType: 'input' | 'output') => {
const nodeRect = nodeElement.getBoundingClientRect();
const canvasRect = canvasAreaRef.value?.getBoundingClientRect();
if (!canvasRect) {
return getCalculatedPortPosition(node, portType);
}
// 计算节点在SVG坐标系中的实际大小和位置
const nodeWidth = nodeRect.width / zoomLevel.value;
const nodeHeight = nodeRect.height / zoomLevel.value;
// 端口位于节点的水平中心
const portX = node.x + nodeWidth / 2;
const portY = portType === 'input'
? node.y - 5
: node.y + nodeHeight + 5;
return { x: portX, y: portY };
};
const startConnection = (event: MouseEvent, nodeId: string, portType: 'input' | 'output') => {
event.preventDefault();
event.stopPropagation();
connectionState.isConnecting = true;
connectionState.startNodeId = nodeId;
connectionState.startPortType = portType;
connectionState.currentMousePos = { x: event.clientX, y: event.clientY };
const startPos = getPortPosition(nodeId, portType);
if (startPos) {
connectionState.startPortPos = startPos;
}
document.addEventListener('mousemove', onConnectionDrag);
document.addEventListener('mouseup', onConnectionEnd);
if (canvasAreaRef.value) {
canvasAreaRef.value.classList.add('connecting');
}
};
// 连接拖拽
const onConnectionDrag = (event: MouseEvent) => {
if (!connectionState.isConnecting || !connectionState.startNodeId || !connectionState.startPortType) return;
connectionState.currentMousePos = { x: event.clientX, y: event.clientY };
const svgPos = clientToSVGCoordinates(event.clientX, event.clientY);
const startPos = getPortPosition(connectionState.startNodeId, connectionState.startPortType);
if (startPos && svgPos) {
const controlOffset = Math.abs(svgPos.y - startPos.y) * 0.5;
let path: string;
if (connectionState.startPortType === 'output') {
path = `M ${startPos.x} ${startPos.y} C ${startPos.x} ${startPos.y + controlOffset} ${svgPos.x} ${svgPos.y - controlOffset} ${svgPos.x} ${svgPos.y}`;
} else {
path = `M ${startPos.x} ${startPos.y} C ${startPos.x} ${startPos.y - controlOffset} ${svgPos.x} ${svgPos.y + controlOffset} ${svgPos.x} ${svgPos.y}`;
}
if ('tempPath' in connectionState) {
(connectionState as any).tempPath = path;
}
}
const targetPort = findTargetPort(event.clientX, event.clientY);
if (targetPort && targetPort.nodeId !== connectionState.startNodeId) {
connectionState.hoveredPort = targetPort;
} else {
connectionState.hoveredPort = null;
}
};
// 结束连接
const onConnectionEnd = (event: MouseEvent) => {
if (!connectionState.isConnecting) return;
// 检查是否落在有效的端口上
const targetPort = findTargetPort(event.clientX, event.clientY);
if (targetPort && connectionState.startNodeId && connectionState.startPortType) {
const canConnectResult = canConnect(
connectionState.startNodeId,
connectionState.startPortType,
targetPort.nodeId,
targetPort.portType
);
if (canConnectResult) {
let parentId: string, childId: string;
if (connectionState.startPortType === 'output') {
parentId = connectionState.startNodeId;
childId = targetPort.nodeId;
} else {
parentId = targetPort.nodeId;
childId = connectionState.startNodeId;
}
createConnection(parentId, childId);
}
}
// 清理连接状态
cancelConnection();
};
// 取消连接
const cancelConnection = () => {
connectionState.isConnecting = false;
connectionState.startNodeId = null;
connectionState.startPortType = null;
connectionState.currentMousePos = null;
connectionState.startPortPos = null;
connectionState.hoveredPort = null;
if ('tempPath' in connectionState) {
(connectionState as any).tempPath = '';
}
document.removeEventListener('mousemove', onConnectionDrag);
document.removeEventListener('mouseup', onConnectionEnd);
if (canvasAreaRef.value) {
canvasAreaRef.value.classList.remove('connecting');
}
// 清除画布内的拖拽目标样式
if (canvasAreaRef.value) {
const allPorts = canvasAreaRef.value.querySelectorAll('.port.drag-target');
allPorts.forEach(port => port.classList.remove('drag-target'));
}
};
const clientToSVGCoordinates = (clientX: number, clientY: number) => {
if (!canvasAreaRef.value) return null;
try {
// 获取canvas容器的边界
const canvasRect = canvasAreaRef.value.getBoundingClientRect();
// 转换为相对于canvas的坐标
const canvasX = clientX - canvasRect.left;
const canvasY = clientY - canvasRect.top;
// 撤销SVG的transform转换为SVG坐标
// SVG transform: translate(panX, panY) scale(zoomLevel)
const svgX = (canvasX - panX.value) / zoomLevel.value;
const svgY = (canvasY - panY.value) / zoomLevel.value;
return { x: svgX, y: svgY };
} catch (e) {
return null;
}
};
// 查找目标端口
const findTargetPort = (clientX: number, clientY: number) => {
if (!canvasAreaRef.value) return null;
try {
const elementAtPoint = document.elementFromPoint(clientX, clientY);
if (elementAtPoint?.classList.contains('port') && canvasAreaRef.value.contains(elementAtPoint)) {
return getPortInfo(elementAtPoint as HTMLElement);
}
} catch (error) {
console.warn(`[ConnectionManager] elementFromPoint 查询出错:`, error);
}
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' as '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)) {
// 在画布区域内查找端口元素
if (canvasAreaRef.value) {
const portElement = canvasAreaRef.value.querySelector(`[data-node-id="${nodeId}"] .port.port-${portType}`);
if (portElement) {
portElement.classList.add('drag-target');
}
}
}
}
};
const onPortLeave = () => {
if (connectionState.isConnecting) {
connectionState.hoveredPort = null;
// 清除画布内的拖拽目标样式
if (canvasAreaRef.value) {
const allPorts = canvasAreaRef.value.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 = treeNodes.value.find(n => n.id === childNodeId);
if (childNode && childNode.parent && childNode.parent !== parentNodeId) {
return false;
}
const parentNode = treeNodes.value.find(n => 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 = treeNodes.value.find(n => n.id === currentId);
if (currentNode?.children) {
for (const childId of currentNode.children) {
if (checkPath(childId)) return true;
}
}
return false;
}
return checkPath(descendantId);
};
// 创建连接
const createConnection = (parentId: string, childId: string) => {
const parentNode = treeNodes.value.find(n => n.id === parentId);
const childNode = treeNodes.value.find(n => n.id === childId);
if (!parentNode || !childNode) return;
// 移除子节点的旧父子关系
if (childNode.parent) {
const oldParent = treeNodes.value.find(n => n.id === childNode.parent);
if (oldParent) {
const index = oldParent.children.indexOf(childId);
if (index > -1) {
oldParent.children.splice(index, 1);
}
}
}
// 建立新的父子关系
childNode.parent = parentId;
if (!parentNode.children.includes(childId)) {
parentNode.children.push(childId);
}
updateConnections();
};
// 更新连接线
const updateConnections = () => {
connections.value.length = 0;
// 添加一个小延迟确保DOM已经更新
setTimeout(() => {
treeNodes.value.forEach(node => {
if (node.children) {
node.children.forEach(childId => {
const childNode = treeNodes.value.find(n => 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}`;
connections.value.push({
id: `${node.id}-${childId}`,
sourceId: node.id,
targetId: childId,
path: path,
active: false
});
}
}
});
}
});
}, 50); // 50ms延迟确保DOM渲染完成
};
return {
getPortPosition,
startConnection,
cancelConnection,
updateConnections,
onPortHover,
onPortLeave,
isValidConnectionTarget
};
}

View File

@@ -1,4 +1,4 @@
import { Ref } from 'vue'; import { Ref, ref, watch } from 'vue';
import { TreeNode, Connection } from '../types'; import { TreeNode, Connection } from '../types';
/** /**
@@ -9,28 +9,417 @@ export function useFileOperations(
selectedNodeId: Ref<string | null>, selectedNodeId: Ref<string | null>,
connections: Ref<Connection[]>, connections: Ref<Connection[]>,
tempConnection: Ref<{ path: string }>, tempConnection: Ref<{ path: string }>,
showExportModal: Ref<boolean> showExportModal: Ref<boolean>,
codeGeneration?: {
createTreeFromConfig: (config: any) => TreeNode[];
},
updateConnections?: () => void
) { ) {
// 跟踪未保存状态
const hasUnsavedChanges = ref(false);
const lastSavedState = ref<string>('');
const currentFileName = ref('');
// 监听树结构变化来更新未保存状态
const updateUnsavedStatus = () => {
const currentState = JSON.stringify({
nodes: treeNodes.value,
connections: connections.value
});
hasUnsavedChanges.value = currentState !== lastSavedState.value;
};
// 监听变化
watch([treeNodes, connections], updateUnsavedStatus, { deep: true });
// 标记为已保存
const markAsSaved = () => {
const currentState = JSON.stringify({
nodes: treeNodes.value,
connections: connections.value
});
lastSavedState.value = currentState;
hasUnsavedChanges.value = false;
};
// 检查是否需要保存的通用方法
const checkUnsavedChanges = (): Promise<boolean> => {
return new Promise((resolve) => {
if (!hasUnsavedChanges.value) {
resolve(true);
return;
}
const result = confirm(
'当前行为树有未保存的更改,是否要保存?\n\n' +
'点击"确定"保存更改\n' +
'点击"取消"丢弃更改\n' +
'点击"X"取消操作'
);
if (result) {
// 用户选择保存
saveBehaviorTree().then(() => {
resolve(true);
}).catch(() => {
resolve(false);
});
} else {
// 用户选择丢弃更改
resolve(true);
}
});
};
// 导出行为树数据
const exportBehaviorTreeData = () => {
return {
nodes: treeNodes.value,
connections: connections.value,
metadata: {
name: currentFileName.value || 'untitled',
created: new Date().toISOString(),
version: '1.0'
}
};
};
// 工具栏操作 // 工具栏操作
const newBehaviorTree = () => { const newBehaviorTree = async () => {
const canProceed = await checkUnsavedChanges();
if (canProceed) {
treeNodes.value = []; treeNodes.value = [];
selectedNodeId.value = null; selectedNodeId.value = null;
connections.value = []; connections.value = [];
tempConnection.value.path = ''; tempConnection.value.path = '';
currentFileName.value = '';
markAsSaved(); // 新建后标记为已保存状态
}
}; };
const saveBehaviorTree = () => { // 保存行为树
// TODO: 实现保存功能 const saveBehaviorTree = async (): Promise<boolean> => {
console.log('保存行为树'); console.log('=== 开始保存行为树 ===');
try {
const data = exportBehaviorTreeData();
const jsonString = JSON.stringify(data, null, 2);
console.log('数据准备完成JSON长度:', jsonString.length);
// 使用 HTML input 替代 prompt因为 prompt 在 Cocos Creator 扩展中不支持)
const fileName = await getFileNameFromUser();
if (!fileName) {
console.log('❌ 用户取消了保存操作');
return false;
}
console.log('✓ 用户输入文件名:', fileName);
// 检测是否在Cocos Creator环境中
if (typeof Editor !== 'undefined' && typeof (window as any).sendToMain === 'function') {
console.log('✓ 使用Cocos Creator保存方式');
try {
(window as any).sendToMain('create-behavior-tree-from-editor', {
fileName: fileName + '.json',
content: jsonString,
timestamp: new Date().toISOString()
});
console.log('✓ 保存消息已发送到主进程');
// 更新当前文件名并标记为已保存
currentFileName.value = fileName;
markAsSaved();
// 用户反馈
showMessage(`保存成功!文件名: ${fileName}.json`, 'success');
console.log('✅ 保存操作完成');
return true;
} catch (sendError) {
console.error('❌ 发送消息时出错:', sendError);
showMessage('保存失败: ' + sendError, 'error');
return false;
}
} else {
console.log('✓ 使用浏览器下载保存方式');
// 在浏览器环境中使用下载方式
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// 标记为已保存
currentFileName.value = fileName;
markAsSaved();
console.log('✅ 文件下载保存成功');
return true;
}
} catch (error) {
console.error('❌ 保存过程中发生错误:', error);
showMessage('保存失败: ' + error, 'error');
return false;
}
}; };
const loadBehaviorTree = () => { // 使用 HTML input 获取文件名(替代 prompt
// TODO: 实现加载功能 const getFileNameFromUser = (): Promise<string | null> => {
console.log('加载行为树'); return new Promise((resolve) => {
// 创建模态对话框
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
`;
const dialog = document.createElement('div');
dialog.style.cssText = `
background: #2d2d2d;
color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
min-width: 300px;
`;
dialog.innerHTML = `
<h3 style="margin: 0 0 15px 0; color: #ffffff;">保存行为树</h3>
<p style="margin: 0 0 15px 0; color: #cccccc;">请输入文件名(不含扩展名):</p>
<input type="text" id="filename-input" value="${currentFileName.value || 'behavior_tree'}"
style="width: 100%; padding: 8px; border: 1px solid #555; background: #1a1a1a; color: #ffffff; border-radius: 4px; margin-bottom: 15px;">
<div style="text-align: right;">
<button id="cancel-btn" style="padding: 8px 16px; margin-right: 8px; background: #555; color: #fff; border: none; border-radius: 4px; cursor: pointer;">取消</button>
<button id="save-btn" style="padding: 8px 16px; background: #007acc; color: #fff; border: none; border-radius: 4px; cursor: pointer;">保存</button>
</div>
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
const input = dialog.querySelector('#filename-input') as HTMLInputElement;
const saveBtn = dialog.querySelector('#save-btn') as HTMLButtonElement;
const cancelBtn = dialog.querySelector('#cancel-btn') as HTMLButtonElement;
// 聚焦并选中文本
input.focus();
input.select();
// 事件处理
const cleanup = () => {
document.body.removeChild(overlay);
};
saveBtn.onclick = () => {
const fileName = input.value.trim();
cleanup();
resolve(fileName || null);
};
cancelBtn.onclick = () => {
cleanup();
resolve(null);
};
// 回车键保存
input.onkeydown = (e) => {
if (e.key === 'Enter') {
const fileName = input.value.trim();
cleanup();
resolve(fileName || null);
} else if (e.key === 'Escape') {
cleanup();
resolve(null);
}
};
});
}; };
const exportCode = () => { // 显示消息提示
const showMessage = (message: string, type: 'success' | 'error' = 'success') => {
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
background: ${type === 'success' ? '#4caf50' : '#f44336'};
color: white;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 10001;
opacity: 0;
transform: translateX(100%);
transition: all 0.3s ease;
`;
toast.textContent = message;
document.body.appendChild(toast);
// 动画显示
setTimeout(() => {
toast.style.opacity = '1';
toast.style.transform = 'translateX(0)';
}, 10);
// 3秒后自动消失
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}, 3000);
};
// 生成当前行为树的配置
const generateCurrentConfig = () => {
if (treeNodes.value.length === 0) return null;
const rootNode = treeNodes.value.find(node =>
!treeNodes.value.some(otherNode =>
otherNode.children?.includes(node.id)
)
);
if (!rootNode) return null;
return {
version: "1.0.0",
type: "behavior-tree",
metadata: {
createdAt: new Date().toISOString(),
nodeCount: treeNodes.value.length
},
tree: generateNodeConfig(rootNode)
};
};
// 简化的节点配置生成(用于文件保存)
const generateNodeConfig = (node: TreeNode): any => {
const config: any = {
id: node.id,
type: node.type,
namespace: getNodeNamespace(node.type),
properties: {}
};
// 处理节点属性
if (node.properties) {
Object.entries(node.properties).forEach(([key, prop]) => {
if (prop.value !== undefined && prop.value !== '') {
config.properties[key] = {
type: prop.type,
value: prop.value
};
}
});
}
// 处理子节点
if (node.children && node.children.length > 0) {
config.children = node.children
.map(childId => treeNodes.value.find(n => n.id === childId))
.filter(Boolean)
.map(child => generateNodeConfig(child!));
}
return config;
};
// 获取节点命名空间
const getNodeNamespace = (nodeType: string): string => {
// ECS节点
if (['has-component', 'add-component', 'remove-component', 'modify-component',
'has-tag', 'is-active', 'wait-time', 'destroy-entity'].includes(nodeType)) {
return 'ecs-integration/behaviors';
}
// 复合节点
if (['sequence', 'selector', 'parallel', 'parallel-selector',
'random-selector', 'random-sequence'].includes(nodeType)) {
return 'behaviourTree/composites';
}
// 装饰器
if (['repeater', 'inverter', 'always-fail', 'always-succeed',
'until-fail', 'until-success'].includes(nodeType)) {
return 'behaviourTree/decorators';
}
// 动作节点
if (['execute-action', 'log-action', 'wait-action'].includes(nodeType)) {
return 'behaviourTree/actions';
}
// 条件节点
if (['execute-conditional'].includes(nodeType)) {
return 'behaviourTree/conditionals';
}
return 'behaviourTree';
};
const loadBehaviorTree = async () => {
const canProceed = await checkUnsavedChanges();
if (!canProceed) return;
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,.bt';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
try {
const configText = event.target?.result as string;
const config = JSON.parse(configText);
if (codeGeneration) {
const newNodes = codeGeneration.createTreeFromConfig(config);
treeNodes.value = newNodes;
selectedNodeId.value = null;
connections.value = [];
tempConnection.value.path = '';
markAsSaved(); // 加载后标记为已保存状态
console.log('行为树配置加载成功');
if (updateConnections) {
updateConnections();
}
} else {
console.error('代码生成器未初始化');
alert('代码生成器未初始化');
}
} catch (error) {
console.error('加载行为树配置失败:', error);
alert('配置文件格式错误');
}
};
reader.readAsText(file);
}
};
input.click();
};
const exportConfig = () => {
showExportModal.value = true; showExportModal.value = true;
}; };
@@ -59,10 +448,12 @@ export function useFileOperations(
newBehaviorTree, newBehaviorTree,
saveBehaviorTree, saveBehaviorTree,
loadBehaviorTree, loadBehaviorTree,
exportCode, exportConfig,
copyToClipboard, copyToClipboard,
saveToFile, saveToFile,
autoLayout, autoLayout,
validateTree validateTree,
hasUnsavedChanges,
markAsSaved
}; };
} }

View File

@@ -0,0 +1,86 @@
/**
* 节点显示管理功能
*/
export function useNodeDisplay() {
// 检查节点是否有可见属性
const hasVisibleProperties = (node: any) => {
if (!node.properties) return false;
return Object.keys(getVisibleProperties(node)).length > 0;
};
// 获取可见属性
const getVisibleProperties = (node: any) => {
if (!node.properties) return {};
const visibleProps: any = {};
for (const [key, prop] of Object.entries(node.properties)) {
if (shouldShowProperty(prop as any, key)) {
visibleProps[key] = prop;
}
}
return visibleProps;
};
// 判断属性是否应该显示
const shouldShowProperty = (prop: any, key: string) => {
// 总是显示这些重要属性
const alwaysShow = ['abortType', 'repeatCount', 'priority'];
if (alwaysShow.includes(key)) {
return true;
}
// 对于其他属性,只在非默认值时显示
if (prop.type === 'string' && prop.value && prop.value.trim() !== '') {
return true;
}
if (prop.type === 'number' && prop.value !== 0 && prop.value !== -1) {
return true;
}
if (prop.type === 'boolean' && prop.value === true) {
return true;
}
if (prop.type === 'select' && prop.value !== 'None' && prop.value !== '') {
return true;
}
if (prop.type === 'code' && prop.value && prop.value.trim() !== '' && prop.value !== '(context) => true') {
return true;
}
return false;
};
// 格式化属性值显示
const formatPropertyValue = (prop: any) => {
switch (prop.type) {
case 'boolean':
return prop.value ? '✓' : '✗';
case 'number':
return prop.value.toString();
case 'select':
return prop.value;
case 'string':
return prop.value.length > 15 ? prop.value.substring(0, 15) + '...' : prop.value;
case 'code':
const code = prop.value || '';
if (code.length > 20) {
// 尝试提取函数体的关键部分
const bodyMatch = code.match(/=>\s*(.+)/) || code.match(/{\s*(.+?)\s*}/);
if (bodyMatch) {
const body = bodyMatch[1].trim();
return body.length > 15 ? body.substring(0, 15) + '...' : body;
}
return code.substring(0, 20) + '...';
}
return code;
default:
return prop.value?.toString() || '';
}
};
return {
hasVisibleProperties,
getVisibleProperties,
formatPropertyValue
};
}

View File

@@ -1,4 +1,4 @@
import { Ref } from 'vue'; import { Ref, nextTick } from 'vue';
import { TreeNode, Connection } from '../types'; import { TreeNode, Connection } from '../types';
import { NodeTemplate } from '../data/nodeTemplates'; import { NodeTemplate } from '../data/nodeTemplates';
import { createNodeFromTemplate } from '../utils/nodeUtils'; import { createNodeFromTemplate } from '../utils/nodeUtils';
@@ -14,7 +14,8 @@ export function useNodeOperations(
panX: Ref<number>, panX: Ref<number>,
panY: Ref<number>, panY: Ref<number>,
zoomLevel: Ref<number>, zoomLevel: Ref<number>,
getNodeByIdLocal: (id: string) => TreeNode | undefined getNodeByIdLocal: (id: string) => TreeNode | undefined,
updateConnections?: () => void
) { ) {
// 获取相对于画布的坐标(用于节点拖放等操作) // 获取相对于画布的坐标(用于节点拖放等操作)
@@ -94,31 +95,73 @@ export function useNodeOperations(
if (selectedNodeId.value === nodeId) { if (selectedNodeId.value === nodeId) {
selectedNodeId.value = null; selectedNodeId.value = null;
} }
// 更新连接线
if (updateConnections) {
updateConnections();
}
};
// 通用的属性更新方法
const setNestedProperty = (obj: any, path: string, value: any) => {
const keys = path.split('.');
let current = obj;
// 导航到目标属性的父对象
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {
current[key] = {};
}
current = current[key];
}
// 设置最终值
const finalKey = keys[keys.length - 1];
current[finalKey] = value;
}; };
// 节点属性更新 // 节点属性更新
const updateNodeProperty = (path: string, value: any) => { const updateNodeProperty = (path: string, value: any) => {
console.log('updateNodeProperty called:', path, value);
const node = selectedNodeId.value ? getNodeByIdLocal(selectedNodeId.value) : null; const node = selectedNodeId.value ? getNodeByIdLocal(selectedNodeId.value) : null;
if (!node) return; if (!node) {
console.log('No selected node found');
// 确保 properties 对象存在 return;
if (!node.properties) {
node.properties = {};
} }
const keys = path.split('.'); console.log('Current node before update:', JSON.stringify(node, null, 2));
let target: any = node.properties;
// 导航到目标对象,如果中间对象不存在则创建 // 使用通用方法更新属性
for (let i = 0; i < keys.length - 1; i++) { setNestedProperty(node, path, value);
if (!target[keys[i]] || typeof target[keys[i]] !== 'object') {
target[keys[i]] = {}; console.log(`Updated property ${path} to:`, value);
} console.log('Updated node after change:', JSON.stringify(node, null, 2));
target = target[keys[i]];
// 强制触发响应式更新 - 创建新数组来强制Vue检测变化
const nodeIndex = treeNodes.value.findIndex(n => n.id === node.id);
if (nodeIndex > -1) {
// 创建新的节点数组确保Vue能检测到变化
const newNodes = [...treeNodes.value];
newNodes[nodeIndex] = { ...node }; // 创建节点副本确保响应式更新
treeNodes.value = newNodes;
console.log('Triggered reactive update - replaced array');
// 验证更新是否成功
nextTick(() => {
const verifyNode = treeNodes.value.find(n => n.id === node.id);
console.log('Verification - node after update:', JSON.stringify(verifyNode, null, 2));
// 验证属性值
const pathParts = path.split('.');
let checkValue: any = verifyNode;
for (const part of pathParts) {
checkValue = checkValue?.[part];
}
console.log(`Verification - final value at ${path}:`, checkValue);
});
} }
// 设置最终值
target[keys[keys.length - 1]] = value;
}; };
return { return {

View File

@@ -386,6 +386,13 @@ export const nodeTemplates: NodeTemplate[] = [
value: 'Component', value: 'Component',
description: '要添加的组件类型名称', description: '要添加的组件类型名称',
required: true required: true
},
componentFactory: {
name: '组件工厂函数',
type: 'code',
value: '() => new Component()',
description: '创建组件实例的函数(可选)',
required: false
} }
} }
}, },

View File

@@ -21,6 +21,18 @@ module.exports = Editor.Panel.define({
methods: { methods: {
sendToMain(message: string, ...args: any[]) { sendToMain(message: string, ...args: any[]) {
Editor.Message.send('cocos-ecs-extension', message, ...args); Editor.Message.send('cocos-ecs-extension', message, ...args);
},
loadBehaviorTreeFile(fileData: any) {
console.log('Loading behavior tree file:', fileData);
// 通知编辑器组件加载文件
if (this.$.app) {
const event = new CustomEvent('load-behavior-tree-file', {
detail: fileData
});
this.$.app.dispatchEvent(event);
}
} }
}, },
@@ -29,6 +41,9 @@ module.exports = Editor.Panel.define({
const app = createApp({}); const app = createApp({});
app.config.compilerOptions.isCustomElement = (tag) => tag.startsWith('ui-'); app.config.compilerOptions.isCustomElement = (tag) => tag.startsWith('ui-');
// 暴露发送消息到主进程的方法
(window as any).sendToMain = this.sendToMain.bind(this);
// 树节点组件 // 树节点组件
app.component('tree-node-item', defineComponent({ app.component('tree-node-item', defineComponent({
props: ['node', 'level', 'getNodeByIdLocal'], props: ['node', 'level', 'getNodeByIdLocal'],

View File

@@ -57,3 +57,12 @@ export interface CanvasCoordinates {
x: number; x: number;
y: number; y: number;
} }
export interface ConnectionState {
isConnecting: boolean;
startNodeId: string | null;
startPortType: 'input' | 'output' | null;
currentMousePos: { x: number; y: number } | null;
startPortPos: { x: number; y: number } | null;
hoveredPort: { nodeId: string; portType: 'input' | 'output' } | null;
}

View File

@@ -1,208 +0,0 @@
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

@@ -1,184 +0,0 @@
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

@@ -1,334 +0,0 @@
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

@@ -1,219 +0,0 @@
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

@@ -1,229 +0,0 @@
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

@@ -33,6 +33,17 @@
text-shadow: 0 2px 4px rgba(0,0,0,0.3); text-shadow: 0 2px 4px rgba(0,0,0,0.3);
} }
.unsaved-indicator {
color: #ff6b6b;
animation: pulse-unsaved 2s infinite;
margin-left: 8px;
}
@keyframes pulse-unsaved {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.toolbar-buttons { .toolbar-buttons {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -57,6 +68,21 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.tool-btn.has-changes {
background: rgba(255, 107, 107, 0.2);
border-color: #ff6b6b;
animation: glow-save 2s infinite;
}
@keyframes glow-save {
0%, 100% {
box-shadow: 0 0 5px rgba(255, 107, 107, 0.5);
}
50% {
box-shadow: 0 0 15px rgba(255, 107, 107, 0.8);
}
}
.toolbar-right .install-status { .toolbar-right .install-status {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -215,7 +241,7 @@
.canvas-area { .canvas-area {
flex: 1; flex: 1;
position: relative; position: relative;
overflow: hidden; overflow: auto;
background: background:
radial-gradient(circle at 20px 20px, #4a5568 1px, transparent 1px), radial-gradient(circle at 20px 20px, #4a5568 1px, transparent 1px),
linear-gradient(90deg, transparent 19px, #2d3748 20px, transparent 21px), linear-gradient(90deg, transparent 19px, #2d3748 20px, transparent 21px),
@@ -228,6 +254,7 @@
top: 0; top: 0;
left: 0; left: 0;
pointer-events: none; pointer-events: none;
z-index: 0;
} }
.connection-layer { .connection-layer {
@@ -237,6 +264,7 @@
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 1;
will-change: transform; will-change: transform;
overflow: visible;
} }
.connection-line { .connection-line {
@@ -287,11 +315,17 @@
z-index: 2; z-index: 2;
transform-origin: 0 0; transform-origin: 0 0;
will-change: transform; will-change: transform;
overflow: visible;
/* 硬件加速优化 */
transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000px;
} }
.tree-node { .tree-node {
position: absolute; position: absolute;
min-width: 150px; min-width: 150px;
min-height: 80px;
background: linear-gradient(145deg, #2d3748 0%, #1a202c 100%); background: linear-gradient(145deg, #2d3748 0%, #1a202c 100%);
border: 2px solid #4a5568; border: 2px solid #4a5568;
border-radius: 12px; border-radius: 12px;
@@ -857,18 +891,21 @@
border: 2px dashed #667eea; border: 2px dashed #667eea;
} }
/* 拖动状态样式 */ /* 拖动状态样式 - 优化硬件加速 */
.tree-node.dragging { .tree-node.dragging {
opacity: 0.9; opacity: 0.9;
transform: scale(1.02) rotate(1deg); transform: translateZ(0) scale(1.02) rotate(1deg);
box-shadow: 0 12px 30px rgba(0,0,0,0.4); box-shadow: 0 12px 30px rgba(0,0,0,0.4);
z-index: 1000; z-index: 1000;
cursor: grabbing; cursor: grabbing;
will-change: transform, opacity; will-change: transform, opacity;
backface-visibility: hidden; backface-visibility: hidden;
transform-style: preserve-3d; transform-style: preserve-3d;
transition: none; transition: none !important;
border-color: #67b7dc; border-color: #67b7dc;
/* 强制硬件加速 */
transform-origin: center center;
perspective: 1000px;
} }
/* 节点悬停时的端口显示优化 */ /* 节点悬停时的端口显示优化 */
@@ -900,3 +937,71 @@
opacity: 0.7; opacity: 0.7;
transform: rotate(5deg); transform: rotate(5deg);
} }
/* 节点属性预览样式 */
.node-properties-preview {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 10px;
}
.property-preview-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2px;
padding: 1px 0;
}
.property-label {
color: #a0aec0;
font-weight: 500;
flex-shrink: 0;
margin-right: 4px;
font-size: 9px;
}
.property-value {
color: #e2e8f0;
font-weight: 600;
text-align: right;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 9px;
}
/* 不同类型属性的颜色 */
.property-value.property-boolean {
color: #68d391;
}
.property-value.property-number {
color: #63b3ed;
}
.property-value.property-select {
color: #f6ad55;
}
.property-value.property-string {
color: #cbd5e0;
}
.property-value.property-code {
color: #d69e2e;
font-family: 'Consolas', 'Monaco', monospace;
}
/* 适应节点宽度 */
.tree-node .node-body {
max-width: 146px; /* 节点宽度 - padding */
overflow: hidden;
}
/* 当节点有属性时稍微增加高度空间 */
.tree-node:has(.node-properties-preview) {
min-height: 100px;
}

View File

@@ -1,19 +1,19 @@
<!-- 头部工具栏 --> <!-- 头部工具栏 -->
<div class="header-toolbar"> <div class="header-toolbar">
<div class="toolbar-left"> <div class="toolbar-left">
<h2>🌳 行为树可视化编辑器</h2> <h2>🌳 行为树可视化编辑器 <span v-if="hasUnsavedChanges" class="unsaved-indicator"></span></h2>
<div class="toolbar-buttons"> <div class="toolbar-buttons">
<button class="tool-btn" @click="newBehaviorTree" title="新建行为树"> <button class="tool-btn" @click="newBehaviorTree" title="新建行为树">
<span>📄</span> 新建 <span>📄</span> 新建
</button> </button>
<button class="tool-btn" @click="saveBehaviorTree" title="保存行为树"> <button class="tool-btn" :class="{ 'has-changes': hasUnsavedChanges }" @click="saveBehaviorTree" title="保存行为树">
<span>💾</span> 保存 <span>💾</span> 保存{{ hasUnsavedChanges ? ' *' : '' }}
</button> </button>
<button class="tool-btn" @click="loadBehaviorTree" title="加载行为树"> <button class="tool-btn" @click="loadBehaviorTree" title="加载行为树">
<span>📂</span> 加载 <span>📂</span> 加载
</button> </button>
<button class="tool-btn" @click="exportCode" title="导出代码"> <button class="tool-btn" @click="exportConfig" title="导出配置">
<span></span> 导出代码 <span></span> 导出配置
</button> </button>
</div> </div>
</div> </div>
@@ -205,7 +205,7 @@
{ {
'node-selected': selectedNodeId === node.id, 'node-selected': selectedNodeId === node.id,
'node-error': node.hasError, 'node-error': node.hasError,
'dragging': dragState.dragNodeId === node.id 'dragging': dragState.dragNode && dragState.dragNode.id === node.id
} }
]" ]"
:style="{ :style="{
@@ -220,8 +220,22 @@
<span class="node-title">{{ node.name }}</span> <span class="node-title">{{ node.name }}</span>
<button class="node-delete" @click.stop="deleteNode(node.id)">×</button> <button class="node-delete" @click.stop="deleteNode(node.id)">×</button>
</div> </div>
<div class="node-body" v-if="node.description"> <div class="node-body">
<div class="node-description">{{ node.description }}</div> <div v-if="node.description" class="node-description">{{ node.description }}</div>
<!-- 节点属性预览 -->
<div v-if="hasVisibleProperties(node)" class="node-properties-preview">
<div
v-for="(prop, key) in getVisibleProperties(node)"
:key="key"
class="property-preview-item"
:title="prop.name + ': ' + prop.description"
>
<span class="property-label">{{ prop.name }}:</span>
<span class="property-value" :class="'property-' + prop.type">
{{ formatPropertyValue(prop) }}
</span>
</div>
</div>
</div> </div>
<!-- 输入端口 - 执行流入口 --> <!-- 输入端口 - 执行流入口 -->
<div <div
@@ -276,30 +290,32 @@
<h3>⚙️ 属性面板</h3> <h3>⚙️ 属性面板</h3>
</div> </div>
<div v-if="selectedNode()" class="node-properties"> <div v-if="selectedNode" class="node-properties">
<div class="property-section"> <div class="property-section">
<h4>基本信息</h4> <h4>基本信息</h4>
<div class="property-item"> <div class="property-item">
<label>节点名称:</label> <label>节点名称:</label>
<input <input
type="text" type="text"
:value="selectedNode().name" :value="selectedNode.name"
@input="updateNodeProperty('name', $event.target.value)" @input="updateNodeProperty('name', $event.target.value)"
:key="selectedNode.id + '_name'"
> >
</div> </div>
<div class="property-item"> <div class="property-item">
<label>描述:</label> <label>描述:</label>
<textarea <textarea
:value="selectedNode().description" :value="selectedNode.description"
@input="updateNodeProperty('description', $event.target.value)" @input="updateNodeProperty('description', $event.target.value)"
:key="selectedNode.id + '_description'"
></textarea> ></textarea>
</div> </div>
</div> </div>
<div class="property-section" v-if="selectedNode().properties"> <div class="property-section" v-if="selectedNode.properties">
<h4>节点属性</h4> <h4>节点属性</h4>
<div <div
v-for="(prop, key) in selectedNode().properties" v-for="(prop, key) in selectedNode.properties"
:key="key" :key="key"
class="property-item" class="property-item"
> >
@@ -309,18 +325,21 @@
type="text" type="text"
:value="prop.value" :value="prop.value"
@input="updateNodeProperty('properties.' + key + '.value', $event.target.value)" @input="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
:key="selectedNode.id + '_' + key + '_string'"
> >
<input <input
v-else-if="prop.type === 'number'" v-else-if="prop.type === 'number'"
type="number" type="number"
:value="prop.value" :value="prop.value"
@input="updateNodeProperty('properties.' + key + '.value', parseFloat($event.target.value))" @input="updateNodeProperty('properties.' + key + '.value', parseFloat($event.target.value) || 0)"
:key="selectedNode.id + '_' + key + '_number'"
> >
<input <input
v-else-if="prop.type === 'boolean'" v-else-if="prop.type === 'boolean'"
type="checkbox" type="checkbox"
:checked="prop.value" :checked="prop.value"
@change="updateNodeProperty('properties.' + key + '.value', $event.target.checked)" @change="updateNodeProperty('properties.' + key + '.value', $event.target.checked)"
:key="selectedNode.id + '_' + key + '_boolean'"
> >
<textarea <textarea
v-else-if="prop.type === 'code'" v-else-if="prop.type === 'code'"
@@ -329,13 +348,20 @@
rows="6" rows="6"
class="code-input" class="code-input"
placeholder="请输入代码..." placeholder="请输入代码..."
:key="selectedNode.id + '_' + key + '_code'"
></textarea> ></textarea>
<select <select
v-else-if="prop.type === 'select'" v-else-if="prop.type === 'select'"
:value="prop.value" :value="prop.value"
@change="updateNodeProperty('properties.' + key + '.value', $event.target.value)" @change="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
:key="selectedNode.id + '_' + key + '_select_' + prop.value"
> >
<option v-for="option in prop.options" :key="option" :value="option"> <option
v-for="option in prop.options"
:key="option"
:value="option"
:selected="option === prop.value"
>
{{ option }} {{ option }}
</option> </option>
</select> </select>
@@ -344,8 +370,8 @@
</div> </div>
<div class="property-section"> <div class="property-section">
<h4>代码预览</h4> <h4>节点配置</h4>
<pre class="code-preview">{{ generateNodeCode(selectedNode()) }}</pre> <pre class="config-preview">{{ selectedNode ? JSON.stringify(selectedNode, null, 2) : '{}' }}</pre>
</div> </div>
</div> </div>
@@ -380,16 +406,16 @@
<div v-if="showExportModal" class="modal-overlay" @click="showExportModal = false"> <div v-if="showExportModal" class="modal-overlay" @click="showExportModal = false">
<div class="modal-content" @click.stop> <div class="modal-content" @click.stop>
<div class="modal-header"> <div class="modal-header">
<h3>导出代码</h3> <h3>导出配置</h3>
<button @click="showExportModal = false">×</button> <button @click="showExportModal = false">×</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="export-options"> <div class="export-options">
<label> <label>
<input type="radio" v-model="exportFormat" value="typescript"> TypeScript <input type="radio" v-model="exportFormat" value="json"> JSON配置
</label> </label>
<label> <label>
<input type="radio" v-model="exportFormat" value="json"> JSON <input type="radio" v-model="exportFormat" value="typescript"> TypeScript代码
</label> </label>
</div> </div>
<textarea <textarea