拖拽逻辑更新
This commit is contained in:
63
extensions/cocos/cocos-ecs/assets/behavior_tree.json.bt.json
Normal file
63
extensions/cocos/cocos-ecs/assets/behavior_tree.json.bt.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"ver": "2.0.1",
|
||||
"importer": "json",
|
||||
"imported": true,
|
||||
"uuid": "cb66452d-5cad-46a9-96f9-b62831e0edc3",
|
||||
"files": [
|
||||
".json"
|
||||
],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
14
extensions/cocos/cocos-ecs/assets/resources.meta
Normal file
14
extensions/cocos/cocos-ecs/assets/resources.meta
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,20 @@
|
||||
"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": {
|
||||
"open-panel": {
|
||||
"methods": [
|
||||
@@ -175,6 +189,21 @@
|
||||
"methods": [
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,7 +44,7 @@ export function useAppState() {
|
||||
|
||||
// UI状态
|
||||
const showExportModal = ref(false);
|
||||
const exportFormat = ref('typescript');
|
||||
const exportFormat = ref('json');
|
||||
|
||||
// 工具函数
|
||||
const getNodeByIdLocal = (id: string): TreeNode | undefined => {
|
||||
@@ -62,6 +62,17 @@ export function useAppState() {
|
||||
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 {
|
||||
// 安装状态
|
||||
checkingStatus,
|
||||
@@ -94,6 +105,7 @@ export function useAppState() {
|
||||
// 工具函数
|
||||
getNodeByIdLocal,
|
||||
selectNode,
|
||||
newBehaviorTree
|
||||
newBehaviorTree,
|
||||
updateCanvasSize
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import { useNodeOperations } from './useNodeOperations';
|
||||
import { useCodeGeneration } from './useCodeGeneration';
|
||||
import { useInstallation } from './useInstallation';
|
||||
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 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(
|
||||
appState.nodeTemplates,
|
||||
appState.nodeSearchText,
|
||||
@@ -29,8 +49,13 @@ export function useBehaviorTreeEditor() {
|
||||
appState.panX,
|
||||
appState.panY,
|
||||
appState.zoomLevel,
|
||||
appState.getNodeByIdLocal
|
||||
appState.getNodeByIdLocal,
|
||||
{
|
||||
generateConfigJSON: codeGen.generateConfigJSON,
|
||||
generateTypeScriptCode: codeGen.generateTypeScriptCode
|
||||
}
|
||||
);
|
||||
|
||||
const nodeOps = useNodeOperations(
|
||||
appState.treeNodes,
|
||||
appState.selectedNodeId,
|
||||
@@ -38,629 +63,153 @@ export function useBehaviorTreeEditor() {
|
||||
appState.panX,
|
||||
appState.panY,
|
||||
appState.zoomLevel,
|
||||
appState.getNodeByIdLocal
|
||||
);
|
||||
const codeGen = useCodeGeneration(
|
||||
appState.treeNodes,
|
||||
appState.nodeTemplates,
|
||||
appState.getNodeByIdLocal,
|
||||
() => computedProps.rootNode() || null
|
||||
() => connectionManager.updateConnections()
|
||||
);
|
||||
|
||||
const installation = useInstallation(
|
||||
appState.checkingStatus,
|
||||
appState.isInstalled,
|
||||
appState.version,
|
||||
appState.isInstalling
|
||||
);
|
||||
|
||||
const fileOps = useFileOperations(
|
||||
appState.treeNodes,
|
||||
appState.selectedNodeId,
|
||||
appState.connections,
|
||||
appState.tempConnection,
|
||||
appState.showExportModal
|
||||
appState.showExportModal,
|
||||
codeGen,
|
||||
() => connectionManager.updateConnections()
|
||||
);
|
||||
|
||||
// 连线状态管理 - 使用reactive代替复杂的状态管理
|
||||
const connectionState = reactive({
|
||||
isConnecting: false,
|
||||
startNodeId: null as string | null,
|
||||
startPortType: null as 'input' | 'output' | null,
|
||||
tempPath: '',
|
||||
currentMousePos: { x: 0, y: 0 },
|
||||
hoveredPort: null as { nodeId: string, portType: string } | null
|
||||
currentMousePos: null as { x: number, y: number } | null,
|
||||
startPortPos: null as { x: number, y: number } | null,
|
||||
hoveredPort: null as { nodeId: string, portType: 'input' | 'output' } | null
|
||||
});
|
||||
|
||||
// 连线方法
|
||||
const startConnection = (event: MouseEvent, nodeId: string, portType: 'input' | 'output') => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
connectionState.isConnecting = true;
|
||||
connectionState.startNodeId = nodeId;
|
||||
connectionState.startPortType = portType;
|
||||
|
||||
const startPos = getPortPosition(nodeId, portType);
|
||||
if (startPos) {
|
||||
connectionState.currentMousePos = { x: event.clientX, y: event.clientY };
|
||||
|
||||
(event.target as HTMLElement).setPointerCapture((event as any).pointerId || 1);
|
||||
|
||||
document.addEventListener('pointermove', onConnectionDrag);
|
||||
document.addEventListener('pointerup', onConnectionEnd);
|
||||
document.addEventListener('pointercancel', onConnectionEnd);
|
||||
} else {
|
||||
cancelConnection();
|
||||
}
|
||||
};
|
||||
|
||||
const onConnectionDrag = (event: MouseEvent) => {
|
||||
if (!connectionState.isConnecting || !connectionState.startNodeId) return;
|
||||
|
||||
connectionState.currentMousePos = { x: event.clientX, y: event.clientY };
|
||||
|
||||
const svgPos = clientToSVGCoordinates(event.clientX, event.clientY);
|
||||
const startNode = appState.treeNodes.value.find(n => n.id === connectionState.startNodeId);
|
||||
|
||||
if (startNode && svgPos) {
|
||||
const nodeWidth = 150;
|
||||
const nodeHeight = 100;
|
||||
|
||||
let startX: number, startY: number;
|
||||
|
||||
if (connectionState.startPortType === 'output') {
|
||||
startX = startNode.x + nodeWidth / 2;
|
||||
startY = startNode.y + nodeHeight;
|
||||
} else {
|
||||
startX = startNode.x + nodeWidth / 2;
|
||||
startY = startNode.y;
|
||||
}
|
||||
|
||||
const targetX = svgPos.x;
|
||||
const targetY = svgPos.y;
|
||||
const controlOffset = Math.abs(targetY - startY) * 0.5;
|
||||
|
||||
let path: string;
|
||||
if (connectionState.startPortType === 'output') {
|
||||
path = `M ${startX} ${startY} C ${startX} ${startY + controlOffset} ${targetX} ${targetY - controlOffset} ${targetX} ${targetY}`;
|
||||
} else {
|
||||
path = `M ${startX} ${startY} C ${startX} ${startY - controlOffset} ${targetX} ${targetY + controlOffset} ${targetX} ${targetY}`;
|
||||
}
|
||||
|
||||
connectionState.tempPath = path;
|
||||
}
|
||||
};
|
||||
|
||||
const onConnectionEnd = (event: MouseEvent) => {
|
||||
if (!connectionState.isConnecting) return;
|
||||
|
||||
const targetPort = findTargetPort(event.clientX, event.clientY);
|
||||
|
||||
if (targetPort) {
|
||||
const { nodeId: targetNodeId, portType: targetPortType } = targetPort;
|
||||
|
||||
if (canConnect(connectionState.startNodeId!, connectionState.startPortType!, targetNodeId, targetPortType)) {
|
||||
let parentId: string, childId: string;
|
||||
|
||||
if (connectionState.startPortType === 'output') {
|
||||
parentId = connectionState.startNodeId!;
|
||||
childId = targetNodeId;
|
||||
} else {
|
||||
parentId = targetNodeId;
|
||||
childId = connectionState.startNodeId!;
|
||||
}
|
||||
|
||||
createConnection(parentId, childId);
|
||||
}
|
||||
}
|
||||
|
||||
cancelConnection();
|
||||
};
|
||||
|
||||
const cancelConnection = () => {
|
||||
connectionState.isConnecting = false;
|
||||
connectionState.startNodeId = null;
|
||||
connectionState.startPortType = null;
|
||||
connectionState.tempPath = '';
|
||||
|
||||
// 移除全局事件监听器
|
||||
document.removeEventListener('pointermove', onConnectionDrag);
|
||||
document.removeEventListener('pointerup', onConnectionEnd);
|
||||
document.removeEventListener('pointercancel', onConnectionEnd);
|
||||
};
|
||||
|
||||
// 辅助函数:获取端口在SVG中的坐标(优化计算)
|
||||
const getPortPosition = (nodeId: string, portType: 'input' | 'output') => {
|
||||
const node = appState.treeNodes.value.find(n => n.id === nodeId);
|
||||
if (!node) return null;
|
||||
|
||||
// 使用与连线算法一致的计算方式
|
||||
const nodeWidth = 150;
|
||||
const nodeHeight = 100;
|
||||
const nodeX = node.x + nodeWidth / 2; // 节点中心X
|
||||
|
||||
let nodeY: number;
|
||||
if (portType === 'input') {
|
||||
nodeY = node.y; // 输入端口在顶部
|
||||
} else {
|
||||
nodeY = node.y + nodeHeight; // 输出端口在底部
|
||||
}
|
||||
|
||||
return { x: nodeX, y: nodeY };
|
||||
};
|
||||
|
||||
// 辅助函数:将客户端坐标转换为SVG坐标
|
||||
const clientToSVGCoordinates = (clientX: number, clientY: number) => {
|
||||
if (!svgRef.value) return null;
|
||||
|
||||
const svg = svgRef.value as any; // 类型断言解决SVG方法问题
|
||||
const point = svg.createSVGPoint();
|
||||
point.x = clientX;
|
||||
point.y = clientY;
|
||||
|
||||
try {
|
||||
const svgPoint = point.matrixTransform(svg.getScreenCTM()?.inverse());
|
||||
// 应用当前的缩放和平移
|
||||
return {
|
||||
x: (svgPoint.x - appState.panX.value) / appState.zoomLevel.value,
|
||||
y: (svgPoint.y - appState.panY.value) / appState.zoomLevel.value
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 辅助函数:查找目标端口
|
||||
const findTargetPort = (clientX: number, clientY: number) => {
|
||||
if (!canvasAreaRef.value) return null;
|
||||
|
||||
// 方法1: 使用elementFromPoint
|
||||
const elementAtPoint = document.elementFromPoint(clientX, clientY);
|
||||
if (elementAtPoint?.classList.contains('port')) {
|
||||
return getPortInfo(elementAtPoint as HTMLElement);
|
||||
}
|
||||
|
||||
// 方法2: 遍历所有端口,检查坐标
|
||||
const allPorts = canvasAreaRef.value.querySelectorAll('.port');
|
||||
for (const port of allPorts) {
|
||||
const rect = port.getBoundingClientRect();
|
||||
const margin = 10; // 增加容错范围
|
||||
|
||||
if (clientX >= rect.left - margin && clientX <= rect.right + margin &&
|
||||
clientY >= rect.top - margin && clientY <= rect.bottom + margin) {
|
||||
return getPortInfo(port as HTMLElement);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 辅助函数:从端口元素获取端口信息
|
||||
const getPortInfo = (portElement: HTMLElement) => {
|
||||
const nodeElement = portElement.closest('.tree-node');
|
||||
if (!nodeElement) return null;
|
||||
|
||||
const nodeId = nodeElement.getAttribute('data-node-id');
|
||||
const portType = portElement.classList.contains('port-input') ? 'input' : 'output';
|
||||
|
||||
return nodeId ? { nodeId, portType } : null;
|
||||
};
|
||||
|
||||
// 端口悬停处理
|
||||
const onPortHover = (nodeId: string, portType: 'input' | 'output') => {
|
||||
if (connectionState.isConnecting && connectionState.startNodeId !== nodeId) {
|
||||
connectionState.hoveredPort = { nodeId, portType };
|
||||
|
||||
// 检查是否可以连接
|
||||
if (canConnect(connectionState.startNodeId!, connectionState.startPortType!, nodeId, portType)) {
|
||||
// 添加视觉反馈
|
||||
const portElement = document.querySelector(`[data-node-id="${nodeId}"] .port-${portType}`);
|
||||
if (portElement) {
|
||||
portElement.classList.add('drag-target');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onPortLeave = () => {
|
||||
if (connectionState.isConnecting) {
|
||||
connectionState.hoveredPort = null;
|
||||
|
||||
// 移除所有drag-target类
|
||||
const allPorts = document.querySelectorAll('.port.drag-target');
|
||||
allPorts.forEach(port => port.classList.remove('drag-target'));
|
||||
}
|
||||
};
|
||||
|
||||
// 验证连接目标是否有效 - 排除自己的节点
|
||||
const isValidConnectionTarget = (nodeId: string, portType: 'input' | 'output') => {
|
||||
if (!connectionState.isConnecting || !connectionState.startNodeId || connectionState.startNodeId === nodeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return canConnect(connectionState.startNodeId, connectionState.startPortType!, nodeId, portType);
|
||||
};
|
||||
|
||||
const canConnect = (sourceNodeId: string, sourcePortType: string, targetNodeId: string, targetPortType: string) => {
|
||||
if (sourceNodeId === targetNodeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sourcePortType === targetPortType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let parentNodeId: string, childNodeId: string;
|
||||
|
||||
if (sourcePortType === 'output') {
|
||||
parentNodeId = sourceNodeId;
|
||||
childNodeId = targetNodeId;
|
||||
} else {
|
||||
parentNodeId = targetNodeId;
|
||||
childNodeId = sourceNodeId;
|
||||
}
|
||||
|
||||
const childNode = appState.treeNodes.value.find((n: any) => n.id === childNodeId);
|
||||
if (childNode && childNode.parent && childNode.parent !== parentNodeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parentNode = appState.treeNodes.value.find((n: any) => n.id === parentNodeId);
|
||||
if (!parentNode || !parentNode.canHaveChildren) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!childNode || !childNode.canHaveParent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (wouldCreateCycle(parentNodeId, childNodeId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDescendant(childNodeId, parentNodeId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const wouldCreateCycle = (parentId: string, childId: string) => {
|
||||
return isDescendant(parentId, childId);
|
||||
};
|
||||
|
||||
const isDescendant = (ancestorId: string, descendantId: string): boolean => {
|
||||
const visited = new Set<string>();
|
||||
|
||||
function checkPath(currentId: string): boolean {
|
||||
if (currentId === ancestorId) return true;
|
||||
if (visited.has(currentId)) return false;
|
||||
|
||||
visited.add(currentId);
|
||||
|
||||
const currentNode = appState.treeNodes.value.find((n: any) => n.id === currentId);
|
||||
if (currentNode?.children) {
|
||||
for (const childId of currentNode.children) {
|
||||
if (checkPath(childId)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return checkPath(descendantId);
|
||||
};
|
||||
|
||||
const getAncestors = (nodeId: string): string[] => {
|
||||
const ancestors: string[] = [];
|
||||
let currentNode = appState.treeNodes.value.find((n: any) => n.id === nodeId);
|
||||
|
||||
while (currentNode && currentNode.parent) {
|
||||
ancestors.push(currentNode.parent);
|
||||
const parentId = currentNode.parent;
|
||||
currentNode = appState.treeNodes.value.find((n: any) => n.id === parentId);
|
||||
|
||||
if (ancestors.length > 100) break;
|
||||
}
|
||||
|
||||
return ancestors;
|
||||
};
|
||||
|
||||
const getDescendants = (nodeId: string): string[] => {
|
||||
const descendants: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
function collectDescendants(currentId: string) {
|
||||
if (visited.has(currentId)) return;
|
||||
visited.add(currentId);
|
||||
|
||||
const currentNode = appState.treeNodes.value.find((n: any) => n.id === currentId);
|
||||
if (currentNode?.children) {
|
||||
for (const childId of currentNode.children) {
|
||||
descendants.push(childId);
|
||||
collectDescendants(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectDescendants(nodeId);
|
||||
return descendants;
|
||||
};
|
||||
|
||||
// 创建连接(支持双向连接)
|
||||
const createConnection = (parentId: string, childId: string) => {
|
||||
const parentNode = appState.treeNodes.value.find((n: any) => n.id === parentId);
|
||||
const childNode = appState.treeNodes.value.find((n: any) => n.id === childId);
|
||||
|
||||
if (parentNode && childNode) {
|
||||
// 移除子节点之前的父节点关系
|
||||
if (childNode.parent) {
|
||||
const oldParent = appState.treeNodes.value.find((n: any) => n.id === childNode.parent);
|
||||
if (oldParent && oldParent.children) {
|
||||
oldParent.children = oldParent.children.filter((id: string) => id !== childId);
|
||||
}
|
||||
}
|
||||
|
||||
// 移除可能的重复连接
|
||||
appState.treeNodes.value.forEach((node: any) => {
|
||||
if (node.children) {
|
||||
node.children = node.children.filter((id: string) => !(node.id === parentId && id === childId));
|
||||
}
|
||||
const connectionManager = useConnectionManager(
|
||||
appState.treeNodes,
|
||||
appState.connections,
|
||||
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
|
||||
});
|
||||
|
||||
// 添加新的父子关系
|
||||
if (!parentNode.children) {
|
||||
parentNode.children = [];
|
||||
}
|
||||
if (!parentNode.children.includes(childId)) {
|
||||
parentNode.children.push(childId);
|
||||
}
|
||||
|
||||
// 设置子节点的父节点引用
|
||||
childNode.parent = parentId;
|
||||
|
||||
// 更新连接线
|
||||
updateConnections();
|
||||
}
|
||||
};
|
||||
|
||||
const updateConnections = () => {
|
||||
appState.connections.value.length = 0;
|
||||
|
||||
appState.treeNodes.value.forEach((node: any) => {
|
||||
if (node.children) {
|
||||
node.children.forEach((childId: string) => {
|
||||
const childNode = appState.treeNodes.value.find((n: any) => n.id === childId);
|
||||
if (childNode) {
|
||||
const parentPos = getPortPosition(node.id, 'output');
|
||||
const childPos = getPortPosition(childId, 'input');
|
||||
|
||||
if (parentPos && childPos) {
|
||||
// 使用与临时连线相同的贝塞尔曲线算法
|
||||
const controlOffset = Math.abs(childPos.y - parentPos.y) * 0.5;
|
||||
const path = `M ${parentPos.x} ${parentPos.y} C ${parentPos.x} ${parentPos.y + controlOffset} ${childPos.x} ${childPos.y - controlOffset} ${childPos.x} ${childPos.y}`;
|
||||
|
||||
appState.connections.value.push({
|
||||
id: `${node.id}-${childId}`,
|
||||
sourceId: node.id,
|
||||
targetId: childId,
|
||||
path: path,
|
||||
active: false
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 节点拖拽功能(移除防抖,实时更新)
|
||||
const startNodeDrag = (event: MouseEvent, node: any) => {
|
||||
// 阻止默认行为
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
// 设置拖拽状态
|
||||
appState.dragState.value.isDraggingNode = true;
|
||||
appState.dragState.value.dragNodeId = node.id;
|
||||
appState.dragState.value.dragStartX = event.clientX;
|
||||
appState.dragState.value.dragStartY = event.clientY;
|
||||
appState.dragState.value.dragNodeStartX = node.x;
|
||||
appState.dragState.value.dragNodeStartY = node.y;
|
||||
dragState.isDragging = true;
|
||||
dragState.dragNode = node;
|
||||
dragState.startPosition = { x: event.clientX, y: event.clientY };
|
||||
|
||||
// 添加dragging类提升性能
|
||||
const nodeElement = event.currentTarget as HTMLElement;
|
||||
nodeElement.classList.add('dragging');
|
||||
dragState.dragElement = document.querySelector(`[data-node-id="${node.id}"]`) as HTMLElement;
|
||||
if (dragState.dragElement) {
|
||||
dragState.dragElement.classList.add('dragging');
|
||||
}
|
||||
|
||||
dragState.dragOffset = {
|
||||
x: node.x,
|
||||
y: node.y
|
||||
};
|
||||
|
||||
// 添加全局事件监听(移除passive优化,确保实时性)
|
||||
document.addEventListener('mousemove', onNodeDrag);
|
||||
document.addEventListener('mouseup', onNodeDragEnd);
|
||||
};
|
||||
|
||||
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 deltaY = (event.clientY - appState.dragState.value.dragStartY) / appState.zoomLevel.value;
|
||||
const deltaX = (event.clientX - dragState.startPosition.x) / 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);
|
||||
if (node) {
|
||||
node.x = appState.dragState.value.dragNodeStartX + deltaX;
|
||||
node.y = appState.dragState.value.dragNodeStartY + deltaY;
|
||||
dragState.dragNode.x = dragState.dragOffset.x + deltaX;
|
||||
dragState.dragNode.y = dragState.dragOffset.y + deltaY;
|
||||
|
||||
// 立即更新连接线,无防抖
|
||||
updateConnections();
|
||||
}
|
||||
connectionManager.updateConnections();
|
||||
};
|
||||
|
||||
const onNodeDragEnd = (event: MouseEvent) => {
|
||||
if (appState.dragState.value.isDraggingNode) {
|
||||
// 移除dragging类
|
||||
const draggingNodes = document.querySelectorAll('.tree-node.dragging');
|
||||
draggingNodes.forEach(node => node.classList.remove('dragging'));
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
appState.dragState.value.isDraggingNode = false;
|
||||
appState.dragState.value.dragNodeId = null;
|
||||
if (dragState.dragElement) {
|
||||
dragState.dragElement.classList.remove('dragging');
|
||||
}
|
||||
|
||||
// 最终更新连接线
|
||||
updateConnections();
|
||||
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 = () => {
|
||||
// 这里应该调用installation中的安装方法
|
||||
installation.handleInstall();
|
||||
};
|
||||
|
||||
// 生命周期管理
|
||||
// 组件挂载时初始化连接
|
||||
onMounted(() => {
|
||||
// 初始化连接线
|
||||
// 延迟一下确保 DOM 已经渲染
|
||||
nextTick(() => {
|
||||
updateConnections();
|
||||
connectionManager.updateConnections();
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理事件监听器
|
||||
cancelConnection();
|
||||
document.removeEventListener('mousemove', onNodeDrag);
|
||||
document.removeEventListener('mouseup', onNodeDragEnd);
|
||||
document.removeEventListener('mousemove', onCanvasMouseMove);
|
||||
document.removeEventListener('mouseup', onCanvasMouseUp);
|
||||
});
|
||||
|
||||
// 解构出所有需要的方法,避免命名冲突
|
||||
const {
|
||||
filteredCompositeNodes,
|
||||
filteredDecoratorNodes,
|
||||
filteredActionNodes,
|
||||
filteredConditionNodes,
|
||||
filteredECSNodes,
|
||||
selectedNode,
|
||||
rootNode,
|
||||
installStatusClass,
|
||||
installStatusText,
|
||||
validationResult,
|
||||
exportedCode,
|
||||
gridStyle
|
||||
} = computedProps;
|
||||
|
||||
return {
|
||||
// DOM refs
|
||||
canvasAreaRef,
|
||||
svgRef,
|
||||
|
||||
// 状态
|
||||
...appState,
|
||||
connectionState,
|
||||
|
||||
// 计算属性 - 显式导出,避免命名冲突
|
||||
filteredCompositeNodes,
|
||||
filteredDecoratorNodes,
|
||||
filteredActionNodes,
|
||||
filteredConditionNodes,
|
||||
filteredECSNodes,
|
||||
selectedNode,
|
||||
rootNode,
|
||||
installStatusClass,
|
||||
installStatusText,
|
||||
validationResult,
|
||||
exportedCode,
|
||||
gridStyle,
|
||||
|
||||
// 连线方法
|
||||
startConnection,
|
||||
cancelConnection,
|
||||
updateConnections,
|
||||
onPortHover,
|
||||
onPortLeave,
|
||||
isValidConnectionTarget,
|
||||
|
||||
// 节点拖拽
|
||||
startNodeDrag,
|
||||
|
||||
// 画布操作
|
||||
onCanvasWheel,
|
||||
onCanvasMouseDown,
|
||||
onCanvasMouseMove,
|
||||
onCanvasMouseUp,
|
||||
|
||||
// 缩放控制
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
centerView,
|
||||
|
||||
// 其他功能方法
|
||||
...computedProps,
|
||||
...nodeOps,
|
||||
...fileOps,
|
||||
...codeGen,
|
||||
...installation,
|
||||
...fileOps,
|
||||
handleInstall,
|
||||
connectionState,
|
||||
...connectionManager,
|
||||
...canvasManager,
|
||||
...nodeDisplay,
|
||||
startNodeDrag,
|
||||
dragState
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -12,97 +12,329 @@ export function useCodeGeneration(
|
||||
rootNode: () => TreeNode | null
|
||||
) {
|
||||
|
||||
// TypeScript代码生成
|
||||
const generateTypeScriptCode = (): string => {
|
||||
const imports = getRequiredImports();
|
||||
// 生成行为树配置JSON
|
||||
const generateBehaviorTreeConfig = () => {
|
||||
const root = rootNode();
|
||||
|
||||
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 '// 请先添加根节点';
|
||||
}
|
||||
|
||||
const importsCode = imports.map(imp => `import { ${imp} } from '@esengine/ai';`).join('\n');
|
||||
const treeCode = generateNodeCode(root);
|
||||
|
||||
return `${importsCode}
|
||||
|
||||
// 自动生成的行为树代码
|
||||
export function createBehaviorTree() {
|
||||
return ${treeCode};
|
||||
}`;
|
||||
return JSON.stringify(config, null, 2);
|
||||
};
|
||||
|
||||
const getRequiredImports = (): string[] => {
|
||||
const imports = new Set<string>();
|
||||
// 生成TypeScript构建代码(用于运行时从配置创建行为树)
|
||||
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 => {
|
||||
const template = nodeTemplates.value.find(t => t.className === node.type || t.type === node.type);
|
||||
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 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}`;
|
||||
}
|
||||
|
||||
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 (template.namespace?.includes('ecs-integration')) {
|
||||
// ECS节点的特殊处理
|
||||
switch (template.className) {
|
||||
case 'HasComponentCondition':
|
||||
case 'AddComponentAction':
|
||||
case 'RemoveComponentAction':
|
||||
case 'ModifyComponentAction':
|
||||
if (node.properties?.componentType?.value) {
|
||||
params.push(node.properties.componentType.value);
|
||||
}
|
||||
});
|
||||
|
||||
if (propsCode.length > 0) {
|
||||
params.push(`{\n${spaces} ${propsCode.join(',\n' + spaces + ' ')}\n${spaces}}`);
|
||||
if (template.className === 'AddComponentAction' && node.properties?.componentFactory?.value) {
|
||||
params.push(node.properties.componentFactory.value);
|
||||
}
|
||||
if (template.className === 'ModifyComponentAction' && node.properties?.modifierCode?.value) {
|
||||
params.push(node.properties.modifierCode.value);
|
||||
}
|
||||
break;
|
||||
case 'HasTagCondition':
|
||||
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 += ')';
|
||||
|
||||
// 子节点
|
||||
if (node.children && node.children.length > 0) {
|
||||
// 处理子节点(对于复合节点和装饰器)
|
||||
if (template.canHaveChildren && node.children && node.children.length > 0) {
|
||||
const children = node.children
|
||||
.map(childId => getNodeByIdLocal(childId))
|
||||
.filter(Boolean)
|
||||
.map(child => generateNodeCode(child!, indent + 1));
|
||||
|
||||
if (children.length > 0) {
|
||||
if (params.length > 0) code += ', ';
|
||||
code += '[\n' + children.join(',\n') + '\n' + spaces + ']';
|
||||
const className = template.className; // 保存到局部变量
|
||||
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;
|
||||
};
|
||||
|
||||
// 从配置创建行为树节点
|
||||
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 {
|
||||
generateBehaviorTreeConfig,
|
||||
generateConfigJSON,
|
||||
generateTypeScriptCode,
|
||||
generateNodeCode,
|
||||
generateNodeConfig,
|
||||
createTreeFromConfig,
|
||||
getRequiredImports
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Ref } from 'vue';
|
||||
import { Ref, computed } from 'vue';
|
||||
import { TreeNode } from '../types';
|
||||
import { NodeTemplate } from '../data/nodeTemplates';
|
||||
import { getRootNode } from '../utils/nodeUtils';
|
||||
import { getInstallStatusText, getInstallStatusClass } from '../utils/installUtils';
|
||||
import { generateCode } from '../utils/codeGenerator';
|
||||
import { getGridStyle } from '../utils/canvasUtils';
|
||||
|
||||
/**
|
||||
@@ -22,7 +21,11 @@ export function useComputedProperties(
|
||||
panX: Ref<number>,
|
||||
panY: Ref<number>,
|
||||
zoomLevel: Ref<number>,
|
||||
getNodeByIdLocal: (id: string) => TreeNode | undefined
|
||||
getNodeByIdLocal: (id: string) => TreeNode | undefined,
|
||||
codeGeneration?: {
|
||||
generateConfigJSON: () => string;
|
||||
generateTypeScriptCode: () => string;
|
||||
}
|
||||
) {
|
||||
// 过滤节点
|
||||
const filteredCompositeNodes = () => {
|
||||
@@ -60,10 +63,14 @@ export function useComputedProperties(
|
||||
);
|
||||
};
|
||||
|
||||
// 选中的节点
|
||||
const selectedNode = () => {
|
||||
return selectedNodeId.value ? getNodeByIdLocal(selectedNodeId.value) : null;
|
||||
};
|
||||
// 选中的节点 - 使用computed确保响应式更新
|
||||
const selectedNode = computed(() => {
|
||||
if (!selectedNodeId.value) return null;
|
||||
|
||||
// 直接从treeNodes数组中查找,确保获取最新的节点状态
|
||||
const node = treeNodes.value.find(n => n.id === selectedNodeId.value);
|
||||
return node || null;
|
||||
});
|
||||
|
||||
// 根节点
|
||||
const rootNode = () => {
|
||||
@@ -98,8 +105,16 @@ export function useComputedProperties(
|
||||
|
||||
// 导出代码
|
||||
const exportedCode = () => {
|
||||
if (!codeGeneration) {
|
||||
return '// 代码生成器未初始化';
|
||||
}
|
||||
|
||||
try {
|
||||
return generateCode(treeNodes.value, exportFormat.value);
|
||||
if (exportFormat.value === 'json') {
|
||||
return codeGeneration.generateConfigJSON();
|
||||
} else {
|
||||
return codeGeneration.generateTypeScriptCode();
|
||||
}
|
||||
} catch (error) {
|
||||
return `// 代码生成失败: ${error}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Ref } from 'vue';
|
||||
import { Ref, ref, watch } from 'vue';
|
||||
import { TreeNode, Connection } from '../types';
|
||||
|
||||
/**
|
||||
@@ -9,28 +9,417 @@ export function useFileOperations(
|
||||
selectedNodeId: Ref<string | null>,
|
||||
connections: Ref<Connection[]>,
|
||||
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 = [];
|
||||
selectedNodeId.value = null;
|
||||
connections.value = [];
|
||||
tempConnection.value.path = '';
|
||||
currentFileName.value = '';
|
||||
markAsSaved(); // 新建后标记为已保存状态
|
||||
}
|
||||
};
|
||||
|
||||
const saveBehaviorTree = () => {
|
||||
// TODO: 实现保存功能
|
||||
console.log('保存行为树');
|
||||
// 保存行为树
|
||||
const saveBehaviorTree = async (): Promise<boolean> => {
|
||||
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 = () => {
|
||||
// TODO: 实现加载功能
|
||||
console.log('加载行为树');
|
||||
// 使用 HTML input 获取文件名(替代 prompt)
|
||||
const getFileNameFromUser = (): Promise<string | null> => {
|
||||
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);
|
||||
};
|
||||
|
||||
const exportCode = () => {
|
||||
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 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;
|
||||
};
|
||||
|
||||
@@ -59,10 +448,12 @@ export function useFileOperations(
|
||||
newBehaviorTree,
|
||||
saveBehaviorTree,
|
||||
loadBehaviorTree,
|
||||
exportCode,
|
||||
exportConfig,
|
||||
copyToClipboard,
|
||||
saveToFile,
|
||||
autoLayout,
|
||||
validateTree
|
||||
validateTree,
|
||||
hasUnsavedChanges,
|
||||
markAsSaved
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Ref } from 'vue';
|
||||
import { Ref, nextTick } from 'vue';
|
||||
import { TreeNode, Connection } from '../types';
|
||||
import { NodeTemplate } from '../data/nodeTemplates';
|
||||
import { createNodeFromTemplate } from '../utils/nodeUtils';
|
||||
@@ -14,7 +14,8 @@ export function useNodeOperations(
|
||||
panX: Ref<number>,
|
||||
panY: 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) {
|
||||
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) => {
|
||||
console.log('updateNodeProperty called:', path, value);
|
||||
const node = selectedNodeId.value ? getNodeByIdLocal(selectedNodeId.value) : null;
|
||||
if (!node) return;
|
||||
|
||||
// 确保 properties 对象存在
|
||||
if (!node.properties) {
|
||||
node.properties = {};
|
||||
if (!node) {
|
||||
console.log('No selected node found');
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = path.split('.');
|
||||
let target: any = node.properties;
|
||||
console.log('Current node before update:', JSON.stringify(node, null, 2));
|
||||
|
||||
// 导航到目标对象,如果中间对象不存在则创建
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!target[keys[i]] || typeof target[keys[i]] !== 'object') {
|
||||
target[keys[i]] = {};
|
||||
}
|
||||
target = target[keys[i]];
|
||||
}
|
||||
// 使用通用方法更新属性
|
||||
setNestedProperty(node, path, value);
|
||||
|
||||
// 设置最终值
|
||||
target[keys[keys.length - 1]] = value;
|
||||
console.log(`Updated property ${path} to:`, value);
|
||||
console.log('Updated node after change:', JSON.stringify(node, null, 2));
|
||||
|
||||
// 强制触发响应式更新 - 创建新数组来强制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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -386,6 +386,13 @@ export const nodeTemplates: NodeTemplate[] = [
|
||||
value: 'Component',
|
||||
description: '要添加的组件类型名称',
|
||||
required: true
|
||||
},
|
||||
componentFactory: {
|
||||
name: '组件工厂函数',
|
||||
type: 'code',
|
||||
value: '() => new Component()',
|
||||
description: '创建组件实例的函数(可选)',
|
||||
required: false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -21,6 +21,18 @@ module.exports = Editor.Panel.define({
|
||||
methods: {
|
||||
sendToMain(message: string, ...args: any[]) {
|
||||
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({});
|
||||
app.config.compilerOptions.isCustomElement = (tag) => tag.startsWith('ui-');
|
||||
|
||||
// 暴露发送消息到主进程的方法
|
||||
(window as any).sendToMain = this.sendToMain.bind(this);
|
||||
|
||||
// 树节点组件
|
||||
app.component('tree-node-item', defineComponent({
|
||||
props: ['node', 'level', 'getNodeByIdLocal'],
|
||||
|
||||
@@ -57,3 +57,12 @@ export interface CanvasCoordinates {
|
||||
x: 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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 : '未知错误'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,17 @@
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -57,6 +68,21 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -215,7 +241,7 @@
|
||||
.canvas-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
background:
|
||||
radial-gradient(circle at 20px 20px, #4a5568 1px, transparent 1px),
|
||||
linear-gradient(90deg, transparent 19px, #2d3748 20px, transparent 21px),
|
||||
@@ -228,6 +254,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.connection-layer {
|
||||
@@ -237,6 +264,7 @@
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
will-change: transform;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.connection-line {
|
||||
@@ -287,11 +315,17 @@
|
||||
z-index: 2;
|
||||
transform-origin: 0 0;
|
||||
will-change: transform;
|
||||
overflow: visible;
|
||||
/* 硬件加速优化 */
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
position: absolute;
|
||||
min-width: 150px;
|
||||
min-height: 80px;
|
||||
background: linear-gradient(145deg, #2d3748 0%, #1a202c 100%);
|
||||
border: 2px solid #4a5568;
|
||||
border-radius: 12px;
|
||||
@@ -857,18 +891,21 @@
|
||||
border: 2px dashed #667eea;
|
||||
}
|
||||
|
||||
/* 拖动状态样式 */
|
||||
/* 拖动状态样式 - 优化硬件加速 */
|
||||
.tree-node.dragging {
|
||||
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);
|
||||
z-index: 1000;
|
||||
cursor: grabbing;
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
transform-style: preserve-3d;
|
||||
transition: none;
|
||||
transition: none !important;
|
||||
border-color: #67b7dc;
|
||||
/* 强制硬件加速 */
|
||||
transform-origin: center center;
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
/* 节点悬停时的端口显示优化 */
|
||||
@@ -900,3 +937,71 @@
|
||||
opacity: 0.7;
|
||||
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;
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
<!-- 头部工具栏 -->
|
||||
<div class="header-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<h2>🌳 行为树可视化编辑器</h2>
|
||||
<h2>🌳 行为树可视化编辑器 <span v-if="hasUnsavedChanges" class="unsaved-indicator">●</span></h2>
|
||||
<div class="toolbar-buttons">
|
||||
<button class="tool-btn" @click="newBehaviorTree" title="新建行为树">
|
||||
<span>📄</span> 新建
|
||||
</button>
|
||||
<button class="tool-btn" @click="saveBehaviorTree" title="保存行为树">
|
||||
<span>💾</span> 保存
|
||||
<button class="tool-btn" :class="{ 'has-changes': hasUnsavedChanges }" @click="saveBehaviorTree" title="保存行为树">
|
||||
<span>💾</span> 保存{{ hasUnsavedChanges ? ' *' : '' }}
|
||||
</button>
|
||||
<button class="tool-btn" @click="loadBehaviorTree" title="加载行为树">
|
||||
<span>📂</span> 加载
|
||||
</button>
|
||||
<button class="tool-btn" @click="exportCode" title="导出代码">
|
||||
<span>⚡</span> 导出代码
|
||||
<button class="tool-btn" @click="exportConfig" title="导出配置">
|
||||
<span>⚡</span> 导出配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,7 +205,7 @@
|
||||
{
|
||||
'node-selected': selectedNodeId === node.id,
|
||||
'node-error': node.hasError,
|
||||
'dragging': dragState.dragNodeId === node.id
|
||||
'dragging': dragState.dragNode && dragState.dragNode.id === node.id
|
||||
}
|
||||
]"
|
||||
:style="{
|
||||
@@ -220,8 +220,22 @@
|
||||
<span class="node-title">{{ node.name }}</span>
|
||||
<button class="node-delete" @click.stop="deleteNode(node.id)">×</button>
|
||||
</div>
|
||||
<div class="node-body" v-if="node.description">
|
||||
<div class="node-description">{{ node.description }}</div>
|
||||
<div class="node-body">
|
||||
<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
|
||||
@@ -276,30 +290,32 @@
|
||||
<h3>⚙️ 属性面板</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNode()" class="node-properties">
|
||||
<div v-if="selectedNode" class="node-properties">
|
||||
<div class="property-section">
|
||||
<h4>基本信息</h4>
|
||||
<div class="property-item">
|
||||
<label>节点名称:</label>
|
||||
<input
|
||||
type="text"
|
||||
:value="selectedNode().name"
|
||||
:value="selectedNode.name"
|
||||
@input="updateNodeProperty('name', $event.target.value)"
|
||||
:key="selectedNode.id + '_name'"
|
||||
>
|
||||
</div>
|
||||
<div class="property-item">
|
||||
<label>描述:</label>
|
||||
<textarea
|
||||
:value="selectedNode().description"
|
||||
:value="selectedNode.description"
|
||||
@input="updateNodeProperty('description', $event.target.value)"
|
||||
:key="selectedNode.id + '_description'"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="property-section" v-if="selectedNode().properties">
|
||||
<div class="property-section" v-if="selectedNode.properties">
|
||||
<h4>节点属性</h4>
|
||||
<div
|
||||
v-for="(prop, key) in selectedNode().properties"
|
||||
v-for="(prop, key) in selectedNode.properties"
|
||||
:key="key"
|
||||
class="property-item"
|
||||
>
|
||||
@@ -309,18 +325,21 @@
|
||||
type="text"
|
||||
:value="prop.value"
|
||||
@input="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
|
||||
:key="selectedNode.id + '_' + key + '_string'"
|
||||
>
|
||||
<input
|
||||
v-else-if="prop.type === 'number'"
|
||||
type="number"
|
||||
: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
|
||||
v-else-if="prop.type === 'boolean'"
|
||||
type="checkbox"
|
||||
:checked="prop.value"
|
||||
@change="updateNodeProperty('properties.' + key + '.value', $event.target.checked)"
|
||||
:key="selectedNode.id + '_' + key + '_boolean'"
|
||||
>
|
||||
<textarea
|
||||
v-else-if="prop.type === 'code'"
|
||||
@@ -329,13 +348,20 @@
|
||||
rows="6"
|
||||
class="code-input"
|
||||
placeholder="请输入代码..."
|
||||
:key="selectedNode.id + '_' + key + '_code'"
|
||||
></textarea>
|
||||
<select
|
||||
v-else-if="prop.type === 'select'"
|
||||
:value="prop.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"
|
||||
:selected="option === prop.value"
|
||||
>
|
||||
<option v-for="option in prop.options" :key="option" :value="option">
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -344,8 +370,8 @@
|
||||
</div>
|
||||
|
||||
<div class="property-section">
|
||||
<h4>代码预览</h4>
|
||||
<pre class="code-preview">{{ generateNodeCode(selectedNode()) }}</pre>
|
||||
<h4>节点配置</h4>
|
||||
<pre class="config-preview">{{ selectedNode ? JSON.stringify(selectedNode, null, 2) : '{}' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -380,16 +406,16 @@
|
||||
<div v-if="showExportModal" class="modal-overlay" @click="showExportModal = false">
|
||||
<div class="modal-content" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>导出代码</h3>
|
||||
<h3>导出配置</h3>
|
||||
<button @click="showExportModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="export-options">
|
||||
<label>
|
||||
<input type="radio" v-model="exportFormat" value="typescript"> TypeScript
|
||||
<input type="radio" v-model="exportFormat" value="json"> JSON配置
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" v-model="exportFormat" value="json"> JSON
|
||||
<input type="radio" v-model="exportFormat" value="typescript"> TypeScript代码
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
|
||||
Reference in New Issue
Block a user