拖拽逻辑更新
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"
|
"message": "open-panel"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"asset-menu": [
|
||||||
|
{
|
||||||
|
"path": "i18n:menu.create/ECS Framework",
|
||||||
|
"label": "创建行为树文件",
|
||||||
|
"message": "create-behavior-tree-file",
|
||||||
|
"target": "folder"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "i18n:menu.open",
|
||||||
|
"label": "用行为树编辑器打开",
|
||||||
|
"message": "open-behavior-tree-file",
|
||||||
|
"target": [".bt.json", ".json"]
|
||||||
|
}
|
||||||
|
],
|
||||||
"messages": {
|
"messages": {
|
||||||
"open-panel": {
|
"open-panel": {
|
||||||
"methods": [
|
"methods": [
|
||||||
@@ -175,6 +189,21 @@
|
|||||||
"methods": [
|
"methods": [
|
||||||
"open-behavior-tree-docs"
|
"open-behavior-tree-docs"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"create-behavior-tree-file": {
|
||||||
|
"methods": [
|
||||||
|
"create-behavior-tree-file"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"open-behavior-tree-file": {
|
||||||
|
"methods": [
|
||||||
|
"open-behavior-tree-file"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"create-behavior-tree-from-editor": {
|
||||||
|
"methods": [
|
||||||
|
"create-behavior-tree-from-editor"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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状态
|
// UI状态
|
||||||
const showExportModal = ref(false);
|
const showExportModal = ref(false);
|
||||||
const exportFormat = ref('typescript');
|
const exportFormat = ref('json');
|
||||||
|
|
||||||
// 工具函数
|
// 工具函数
|
||||||
const getNodeByIdLocal = (id: string): TreeNode | undefined => {
|
const getNodeByIdLocal = (id: string): TreeNode | undefined => {
|
||||||
@@ -62,6 +62,17 @@ export function useAppState() {
|
|||||||
tempConnection.value.path = '';
|
tempConnection.value.path = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateCanvasSize = () => {
|
||||||
|
const canvasArea = document.querySelector('.canvas-area') as HTMLElement;
|
||||||
|
if (canvasArea) {
|
||||||
|
const rect = canvasArea.getBoundingClientRect();
|
||||||
|
if (rect.width > 0 && rect.height > 0) {
|
||||||
|
canvasWidth.value = Math.max(rect.width, 800);
|
||||||
|
canvasHeight.value = Math.max(rect.height, 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 安装状态
|
// 安装状态
|
||||||
checkingStatus,
|
checkingStatus,
|
||||||
@@ -94,6 +105,7 @@ export function useAppState() {
|
|||||||
// 工具函数
|
// 工具函数
|
||||||
getNodeByIdLocal,
|
getNodeByIdLocal,
|
||||||
selectNode,
|
selectNode,
|
||||||
newBehaviorTree
|
newBehaviorTree,
|
||||||
|
updateCanvasSize
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,9 @@ import { useNodeOperations } from './useNodeOperations';
|
|||||||
import { useCodeGeneration } from './useCodeGeneration';
|
import { useCodeGeneration } from './useCodeGeneration';
|
||||||
import { useInstallation } from './useInstallation';
|
import { useInstallation } from './useInstallation';
|
||||||
import { useFileOperations } from './useFileOperations';
|
import { useFileOperations } from './useFileOperations';
|
||||||
|
import { useConnectionManager } from './useConnectionManager';
|
||||||
|
import { useCanvasManager } from './useCanvasManager';
|
||||||
|
import { useNodeDisplay } from './useNodeDisplay';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主要的行为树编辑器组合功能
|
* 主要的行为树编辑器组合功能
|
||||||
@@ -16,6 +19,23 @@ export function useBehaviorTreeEditor() {
|
|||||||
|
|
||||||
// 获取其他组合功能
|
// 获取其他组合功能
|
||||||
const appState = useAppState();
|
const appState = useAppState();
|
||||||
|
|
||||||
|
// 临时根节点获取函数
|
||||||
|
const getRootNode = () => {
|
||||||
|
return appState.treeNodes.value.find(node =>
|
||||||
|
!appState.treeNodes.value.some(otherNode =>
|
||||||
|
otherNode.children?.includes(node.id)
|
||||||
|
)
|
||||||
|
) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const codeGen = useCodeGeneration(
|
||||||
|
appState.treeNodes,
|
||||||
|
appState.nodeTemplates,
|
||||||
|
appState.getNodeByIdLocal,
|
||||||
|
getRootNode
|
||||||
|
);
|
||||||
|
|
||||||
const computedProps = useComputedProperties(
|
const computedProps = useComputedProperties(
|
||||||
appState.nodeTemplates,
|
appState.nodeTemplates,
|
||||||
appState.nodeSearchText,
|
appState.nodeSearchText,
|
||||||
@@ -29,8 +49,13 @@ export function useBehaviorTreeEditor() {
|
|||||||
appState.panX,
|
appState.panX,
|
||||||
appState.panY,
|
appState.panY,
|
||||||
appState.zoomLevel,
|
appState.zoomLevel,
|
||||||
appState.getNodeByIdLocal
|
appState.getNodeByIdLocal,
|
||||||
|
{
|
||||||
|
generateConfigJSON: codeGen.generateConfigJSON,
|
||||||
|
generateTypeScriptCode: codeGen.generateTypeScriptCode
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const nodeOps = useNodeOperations(
|
const nodeOps = useNodeOperations(
|
||||||
appState.treeNodes,
|
appState.treeNodes,
|
||||||
appState.selectedNodeId,
|
appState.selectedNodeId,
|
||||||
@@ -38,629 +63,153 @@ export function useBehaviorTreeEditor() {
|
|||||||
appState.panX,
|
appState.panX,
|
||||||
appState.panY,
|
appState.panY,
|
||||||
appState.zoomLevel,
|
appState.zoomLevel,
|
||||||
appState.getNodeByIdLocal
|
|
||||||
);
|
|
||||||
const codeGen = useCodeGeneration(
|
|
||||||
appState.treeNodes,
|
|
||||||
appState.nodeTemplates,
|
|
||||||
appState.getNodeByIdLocal,
|
appState.getNodeByIdLocal,
|
||||||
() => computedProps.rootNode() || null
|
() => connectionManager.updateConnections()
|
||||||
);
|
);
|
||||||
|
|
||||||
const installation = useInstallation(
|
const installation = useInstallation(
|
||||||
appState.checkingStatus,
|
appState.checkingStatus,
|
||||||
appState.isInstalled,
|
appState.isInstalled,
|
||||||
appState.version,
|
appState.version,
|
||||||
appState.isInstalling
|
appState.isInstalling
|
||||||
);
|
);
|
||||||
|
|
||||||
const fileOps = useFileOperations(
|
const fileOps = useFileOperations(
|
||||||
appState.treeNodes,
|
appState.treeNodes,
|
||||||
appState.selectedNodeId,
|
appState.selectedNodeId,
|
||||||
appState.connections,
|
appState.connections,
|
||||||
appState.tempConnection,
|
appState.tempConnection,
|
||||||
appState.showExportModal
|
appState.showExportModal,
|
||||||
|
codeGen,
|
||||||
|
() => connectionManager.updateConnections()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 连线状态管理 - 使用reactive代替复杂的状态管理
|
|
||||||
const connectionState = reactive({
|
const connectionState = reactive({
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
startNodeId: null as string | null,
|
startNodeId: null as string | null,
|
||||||
startPortType: null as 'input' | 'output' | null,
|
startPortType: null as 'input' | 'output' | null,
|
||||||
tempPath: '',
|
tempPath: '',
|
||||||
currentMousePos: { x: 0, y: 0 },
|
currentMousePos: null as { x: number, y: number } | null,
|
||||||
hoveredPort: null as { nodeId: string, portType: string } | null
|
startPortPos: null as { x: number, y: number } | null,
|
||||||
|
hoveredPort: null as { nodeId: string, portType: 'input' | 'output' } | null
|
||||||
});
|
});
|
||||||
|
|
||||||
// 连线方法
|
const connectionManager = useConnectionManager(
|
||||||
const startConnection = (event: MouseEvent, nodeId: string, portType: 'input' | 'output') => {
|
appState.treeNodes,
|
||||||
event.stopPropagation();
|
appState.connections,
|
||||||
event.preventDefault();
|
connectionState,
|
||||||
|
canvasAreaRef,
|
||||||
|
svgRef,
|
||||||
|
appState.panX,
|
||||||
|
appState.panY,
|
||||||
|
appState.zoomLevel
|
||||||
|
);
|
||||||
|
|
||||||
|
const canvasManager = useCanvasManager(
|
||||||
|
appState.panX,
|
||||||
|
appState.panY,
|
||||||
|
appState.zoomLevel,
|
||||||
|
appState.treeNodes,
|
||||||
|
appState.selectedNodeId,
|
||||||
|
canvasAreaRef,
|
||||||
|
connectionManager.updateConnections
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeDisplay = useNodeDisplay();
|
||||||
|
|
||||||
|
const dragState = reactive({
|
||||||
|
isDragging: false,
|
||||||
|
dragNode: null as any,
|
||||||
|
dragElement: null as HTMLElement | null,
|
||||||
|
dragOffset: { x: 0, y: 0 },
|
||||||
|
startPosition: { x: 0, y: 0 },
|
||||||
|
updateCounter: 0
|
||||||
|
});
|
||||||
|
|
||||||
connectionState.isConnecting = true;
|
|
||||||
connectionState.startNodeId = nodeId;
|
|
||||||
connectionState.startPortType = portType;
|
|
||||||
|
|
||||||
const startPos = getPortPosition(nodeId, portType);
|
|
||||||
if (startPos) {
|
|
||||||
connectionState.currentMousePos = { x: event.clientX, y: event.clientY };
|
|
||||||
|
|
||||||
(event.target as HTMLElement).setPointerCapture((event as any).pointerId || 1);
|
|
||||||
|
|
||||||
document.addEventListener('pointermove', onConnectionDrag);
|
|
||||||
document.addEventListener('pointerup', onConnectionEnd);
|
|
||||||
document.addEventListener('pointercancel', onConnectionEnd);
|
|
||||||
} else {
|
|
||||||
cancelConnection();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onConnectionDrag = (event: MouseEvent) => {
|
|
||||||
if (!connectionState.isConnecting || !connectionState.startNodeId) return;
|
|
||||||
|
|
||||||
connectionState.currentMousePos = { x: event.clientX, y: event.clientY };
|
|
||||||
|
|
||||||
const svgPos = clientToSVGCoordinates(event.clientX, event.clientY);
|
|
||||||
const startNode = appState.treeNodes.value.find(n => n.id === connectionState.startNodeId);
|
|
||||||
|
|
||||||
if (startNode && svgPos) {
|
|
||||||
const nodeWidth = 150;
|
|
||||||
const nodeHeight = 100;
|
|
||||||
|
|
||||||
let startX: number, startY: number;
|
|
||||||
|
|
||||||
if (connectionState.startPortType === 'output') {
|
|
||||||
startX = startNode.x + nodeWidth / 2;
|
|
||||||
startY = startNode.y + nodeHeight;
|
|
||||||
} else {
|
|
||||||
startX = startNode.x + nodeWidth / 2;
|
|
||||||
startY = startNode.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetX = svgPos.x;
|
|
||||||
const targetY = svgPos.y;
|
|
||||||
const controlOffset = Math.abs(targetY - startY) * 0.5;
|
|
||||||
|
|
||||||
let path: string;
|
|
||||||
if (connectionState.startPortType === 'output') {
|
|
||||||
path = `M ${startX} ${startY} C ${startX} ${startY + controlOffset} ${targetX} ${targetY - controlOffset} ${targetX} ${targetY}`;
|
|
||||||
} else {
|
|
||||||
path = `M ${startX} ${startY} C ${startX} ${startY - controlOffset} ${targetX} ${targetY + controlOffset} ${targetX} ${targetY}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionState.tempPath = path;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onConnectionEnd = (event: MouseEvent) => {
|
|
||||||
if (!connectionState.isConnecting) return;
|
|
||||||
|
|
||||||
const targetPort = findTargetPort(event.clientX, event.clientY);
|
|
||||||
|
|
||||||
if (targetPort) {
|
|
||||||
const { nodeId: targetNodeId, portType: targetPortType } = targetPort;
|
|
||||||
|
|
||||||
if (canConnect(connectionState.startNodeId!, connectionState.startPortType!, targetNodeId, targetPortType)) {
|
|
||||||
let parentId: string, childId: string;
|
|
||||||
|
|
||||||
if (connectionState.startPortType === 'output') {
|
|
||||||
parentId = connectionState.startNodeId!;
|
|
||||||
childId = targetNodeId;
|
|
||||||
} else {
|
|
||||||
parentId = targetNodeId;
|
|
||||||
childId = connectionState.startNodeId!;
|
|
||||||
}
|
|
||||||
|
|
||||||
createConnection(parentId, childId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelConnection();
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelConnection = () => {
|
|
||||||
connectionState.isConnecting = false;
|
|
||||||
connectionState.startNodeId = null;
|
|
||||||
connectionState.startPortType = null;
|
|
||||||
connectionState.tempPath = '';
|
|
||||||
|
|
||||||
// 移除全局事件监听器
|
|
||||||
document.removeEventListener('pointermove', onConnectionDrag);
|
|
||||||
document.removeEventListener('pointerup', onConnectionEnd);
|
|
||||||
document.removeEventListener('pointercancel', onConnectionEnd);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 辅助函数:获取端口在SVG中的坐标(优化计算)
|
|
||||||
const getPortPosition = (nodeId: string, portType: 'input' | 'output') => {
|
|
||||||
const node = appState.treeNodes.value.find(n => n.id === nodeId);
|
|
||||||
if (!node) return null;
|
|
||||||
|
|
||||||
// 使用与连线算法一致的计算方式
|
|
||||||
const nodeWidth = 150;
|
|
||||||
const nodeHeight = 100;
|
|
||||||
const nodeX = node.x + nodeWidth / 2; // 节点中心X
|
|
||||||
|
|
||||||
let nodeY: number;
|
|
||||||
if (portType === 'input') {
|
|
||||||
nodeY = node.y; // 输入端口在顶部
|
|
||||||
} else {
|
|
||||||
nodeY = node.y + nodeHeight; // 输出端口在底部
|
|
||||||
}
|
|
||||||
|
|
||||||
return { x: nodeX, y: nodeY };
|
|
||||||
};
|
|
||||||
|
|
||||||
// 辅助函数:将客户端坐标转换为SVG坐标
|
|
||||||
const clientToSVGCoordinates = (clientX: number, clientY: number) => {
|
|
||||||
if (!svgRef.value) return null;
|
|
||||||
|
|
||||||
const svg = svgRef.value as any; // 类型断言解决SVG方法问题
|
|
||||||
const point = svg.createSVGPoint();
|
|
||||||
point.x = clientX;
|
|
||||||
point.y = clientY;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const svgPoint = point.matrixTransform(svg.getScreenCTM()?.inverse());
|
|
||||||
// 应用当前的缩放和平移
|
|
||||||
return {
|
|
||||||
x: (svgPoint.x - appState.panX.value) / appState.zoomLevel.value,
|
|
||||||
y: (svgPoint.y - appState.panY.value) / appState.zoomLevel.value
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 辅助函数:查找目标端口
|
|
||||||
const findTargetPort = (clientX: number, clientY: number) => {
|
|
||||||
if (!canvasAreaRef.value) return null;
|
|
||||||
|
|
||||||
// 方法1: 使用elementFromPoint
|
|
||||||
const elementAtPoint = document.elementFromPoint(clientX, clientY);
|
|
||||||
if (elementAtPoint?.classList.contains('port')) {
|
|
||||||
return getPortInfo(elementAtPoint as HTMLElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法2: 遍历所有端口,检查坐标
|
|
||||||
const allPorts = canvasAreaRef.value.querySelectorAll('.port');
|
|
||||||
for (const port of allPorts) {
|
|
||||||
const rect = port.getBoundingClientRect();
|
|
||||||
const margin = 10; // 增加容错范围
|
|
||||||
|
|
||||||
if (clientX >= rect.left - margin && clientX <= rect.right + margin &&
|
|
||||||
clientY >= rect.top - margin && clientY <= rect.bottom + margin) {
|
|
||||||
return getPortInfo(port as HTMLElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 辅助函数:从端口元素获取端口信息
|
|
||||||
const getPortInfo = (portElement: HTMLElement) => {
|
|
||||||
const nodeElement = portElement.closest('.tree-node');
|
|
||||||
if (!nodeElement) return null;
|
|
||||||
|
|
||||||
const nodeId = nodeElement.getAttribute('data-node-id');
|
|
||||||
const portType = portElement.classList.contains('port-input') ? 'input' : 'output';
|
|
||||||
|
|
||||||
return nodeId ? { nodeId, portType } : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 端口悬停处理
|
|
||||||
const onPortHover = (nodeId: string, portType: 'input' | 'output') => {
|
|
||||||
if (connectionState.isConnecting && connectionState.startNodeId !== nodeId) {
|
|
||||||
connectionState.hoveredPort = { nodeId, portType };
|
|
||||||
|
|
||||||
// 检查是否可以连接
|
|
||||||
if (canConnect(connectionState.startNodeId!, connectionState.startPortType!, nodeId, portType)) {
|
|
||||||
// 添加视觉反馈
|
|
||||||
const portElement = document.querySelector(`[data-node-id="${nodeId}"] .port-${portType}`);
|
|
||||||
if (portElement) {
|
|
||||||
portElement.classList.add('drag-target');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPortLeave = () => {
|
|
||||||
if (connectionState.isConnecting) {
|
|
||||||
connectionState.hoveredPort = null;
|
|
||||||
|
|
||||||
// 移除所有drag-target类
|
|
||||||
const allPorts = document.querySelectorAll('.port.drag-target');
|
|
||||||
allPorts.forEach(port => port.classList.remove('drag-target'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 验证连接目标是否有效 - 排除自己的节点
|
|
||||||
const isValidConnectionTarget = (nodeId: string, portType: 'input' | 'output') => {
|
|
||||||
if (!connectionState.isConnecting || !connectionState.startNodeId || connectionState.startNodeId === nodeId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return canConnect(connectionState.startNodeId, connectionState.startPortType!, nodeId, portType);
|
|
||||||
};
|
|
||||||
|
|
||||||
const canConnect = (sourceNodeId: string, sourcePortType: string, targetNodeId: string, targetPortType: string) => {
|
|
||||||
if (sourceNodeId === targetNodeId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourcePortType === targetPortType) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parentNodeId: string, childNodeId: string;
|
|
||||||
|
|
||||||
if (sourcePortType === 'output') {
|
|
||||||
parentNodeId = sourceNodeId;
|
|
||||||
childNodeId = targetNodeId;
|
|
||||||
} else {
|
|
||||||
parentNodeId = targetNodeId;
|
|
||||||
childNodeId = sourceNodeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const childNode = appState.treeNodes.value.find((n: any) => n.id === childNodeId);
|
|
||||||
if (childNode && childNode.parent && childNode.parent !== parentNodeId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentNode = appState.treeNodes.value.find((n: any) => n.id === parentNodeId);
|
|
||||||
if (!parentNode || !parentNode.canHaveChildren) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!childNode || !childNode.canHaveParent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wouldCreateCycle(parentNodeId, childNodeId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDescendant(childNodeId, parentNodeId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const wouldCreateCycle = (parentId: string, childId: string) => {
|
|
||||||
return isDescendant(parentId, childId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDescendant = (ancestorId: string, descendantId: string): boolean => {
|
|
||||||
const visited = new Set<string>();
|
|
||||||
|
|
||||||
function checkPath(currentId: string): boolean {
|
|
||||||
if (currentId === ancestorId) return true;
|
|
||||||
if (visited.has(currentId)) return false;
|
|
||||||
|
|
||||||
visited.add(currentId);
|
|
||||||
|
|
||||||
const currentNode = appState.treeNodes.value.find((n: any) => n.id === currentId);
|
|
||||||
if (currentNode?.children) {
|
|
||||||
for (const childId of currentNode.children) {
|
|
||||||
if (checkPath(childId)) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return checkPath(descendantId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAncestors = (nodeId: string): string[] => {
|
|
||||||
const ancestors: string[] = [];
|
|
||||||
let currentNode = appState.treeNodes.value.find((n: any) => n.id === nodeId);
|
|
||||||
|
|
||||||
while (currentNode && currentNode.parent) {
|
|
||||||
ancestors.push(currentNode.parent);
|
|
||||||
const parentId = currentNode.parent;
|
|
||||||
currentNode = appState.treeNodes.value.find((n: any) => n.id === parentId);
|
|
||||||
|
|
||||||
if (ancestors.length > 100) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ancestors;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDescendants = (nodeId: string): string[] => {
|
|
||||||
const descendants: string[] = [];
|
|
||||||
const visited = new Set<string>();
|
|
||||||
|
|
||||||
function collectDescendants(currentId: string) {
|
|
||||||
if (visited.has(currentId)) return;
|
|
||||||
visited.add(currentId);
|
|
||||||
|
|
||||||
const currentNode = appState.treeNodes.value.find((n: any) => n.id === currentId);
|
|
||||||
if (currentNode?.children) {
|
|
||||||
for (const childId of currentNode.children) {
|
|
||||||
descendants.push(childId);
|
|
||||||
collectDescendants(childId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
collectDescendants(nodeId);
|
|
||||||
return descendants;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 创建连接(支持双向连接)
|
|
||||||
const createConnection = (parentId: string, childId: string) => {
|
|
||||||
const parentNode = appState.treeNodes.value.find((n: any) => n.id === parentId);
|
|
||||||
const childNode = appState.treeNodes.value.find((n: any) => n.id === childId);
|
|
||||||
|
|
||||||
if (parentNode && childNode) {
|
|
||||||
// 移除子节点之前的父节点关系
|
|
||||||
if (childNode.parent) {
|
|
||||||
const oldParent = appState.treeNodes.value.find((n: any) => n.id === childNode.parent);
|
|
||||||
if (oldParent && oldParent.children) {
|
|
||||||
oldParent.children = oldParent.children.filter((id: string) => id !== childId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除可能的重复连接
|
|
||||||
appState.treeNodes.value.forEach((node: any) => {
|
|
||||||
if (node.children) {
|
|
||||||
node.children = node.children.filter((id: string) => !(node.id === parentId && id === childId));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加新的父子关系
|
|
||||||
if (!parentNode.children) {
|
|
||||||
parentNode.children = [];
|
|
||||||
}
|
|
||||||
if (!parentNode.children.includes(childId)) {
|
|
||||||
parentNode.children.push(childId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置子节点的父节点引用
|
|
||||||
childNode.parent = parentId;
|
|
||||||
|
|
||||||
// 更新连接线
|
|
||||||
updateConnections();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateConnections = () => {
|
|
||||||
appState.connections.value.length = 0;
|
|
||||||
|
|
||||||
appState.treeNodes.value.forEach((node: any) => {
|
|
||||||
if (node.children) {
|
|
||||||
node.children.forEach((childId: string) => {
|
|
||||||
const childNode = appState.treeNodes.value.find((n: any) => n.id === childId);
|
|
||||||
if (childNode) {
|
|
||||||
const parentPos = getPortPosition(node.id, 'output');
|
|
||||||
const childPos = getPortPosition(childId, 'input');
|
|
||||||
|
|
||||||
if (parentPos && childPos) {
|
|
||||||
// 使用与临时连线相同的贝塞尔曲线算法
|
|
||||||
const controlOffset = Math.abs(childPos.y - parentPos.y) * 0.5;
|
|
||||||
const path = `M ${parentPos.x} ${parentPos.y} C ${parentPos.x} ${parentPos.y + controlOffset} ${childPos.x} ${childPos.y - controlOffset} ${childPos.x} ${childPos.y}`;
|
|
||||||
|
|
||||||
appState.connections.value.push({
|
|
||||||
id: `${node.id}-${childId}`,
|
|
||||||
sourceId: node.id,
|
|
||||||
targetId: childId,
|
|
||||||
path: path,
|
|
||||||
active: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 节点拖拽功能(移除防抖,实时更新)
|
|
||||||
const startNodeDrag = (event: MouseEvent, node: any) => {
|
const startNodeDrag = (event: MouseEvent, node: any) => {
|
||||||
// 阻止默认行为
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
// 设置拖拽状态
|
dragState.isDragging = true;
|
||||||
appState.dragState.value.isDraggingNode = true;
|
dragState.dragNode = node;
|
||||||
appState.dragState.value.dragNodeId = node.id;
|
dragState.startPosition = { x: event.clientX, y: event.clientY };
|
||||||
appState.dragState.value.dragStartX = event.clientX;
|
|
||||||
appState.dragState.value.dragStartY = event.clientY;
|
|
||||||
appState.dragState.value.dragNodeStartX = node.x;
|
|
||||||
appState.dragState.value.dragNodeStartY = node.y;
|
|
||||||
|
|
||||||
// 添加dragging类提升性能
|
dragState.dragElement = document.querySelector(`[data-node-id="${node.id}"]`) as HTMLElement;
|
||||||
const nodeElement = event.currentTarget as HTMLElement;
|
if (dragState.dragElement) {
|
||||||
nodeElement.classList.add('dragging');
|
dragState.dragElement.classList.add('dragging');
|
||||||
|
}
|
||||||
|
|
||||||
|
dragState.dragOffset = {
|
||||||
|
x: node.x,
|
||||||
|
y: node.y
|
||||||
|
};
|
||||||
|
|
||||||
// 添加全局事件监听(移除passive优化,确保实时性)
|
|
||||||
document.addEventListener('mousemove', onNodeDrag);
|
document.addEventListener('mousemove', onNodeDrag);
|
||||||
document.addEventListener('mouseup', onNodeDragEnd);
|
document.addEventListener('mouseup', onNodeDragEnd);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onNodeDrag = (event: MouseEvent) => {
|
const onNodeDrag = (event: MouseEvent) => {
|
||||||
if (!appState.dragState.value.isDraggingNode || !appState.dragState.value.dragNodeId) return;
|
if (!dragState.isDragging || !dragState.dragNode) return;
|
||||||
|
|
||||||
const deltaX = (event.clientX - appState.dragState.value.dragStartX) / appState.zoomLevel.value;
|
const deltaX = (event.clientX - dragState.startPosition.x) / appState.zoomLevel.value;
|
||||||
const deltaY = (event.clientY - appState.dragState.value.dragStartY) / appState.zoomLevel.value;
|
const deltaY = (event.clientY - dragState.startPosition.y) / appState.zoomLevel.value;
|
||||||
|
|
||||||
const node = appState.treeNodes.value.find((n: any) => n.id === appState.dragState.value.dragNodeId);
|
dragState.dragNode.x = dragState.dragOffset.x + deltaX;
|
||||||
if (node) {
|
dragState.dragNode.y = dragState.dragOffset.y + deltaY;
|
||||||
node.x = appState.dragState.value.dragNodeStartX + deltaX;
|
|
||||||
node.y = appState.dragState.value.dragNodeStartY + deltaY;
|
|
||||||
|
|
||||||
// 立即更新连接线,无防抖
|
connectionManager.updateConnections();
|
||||||
updateConnections();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onNodeDragEnd = (event: MouseEvent) => {
|
const onNodeDragEnd = (event: MouseEvent) => {
|
||||||
if (appState.dragState.value.isDraggingNode) {
|
if (!dragState.isDragging) return;
|
||||||
// 移除dragging类
|
|
||||||
const draggingNodes = document.querySelectorAll('.tree-node.dragging');
|
|
||||||
draggingNodes.forEach(node => node.classList.remove('dragging'));
|
|
||||||
|
|
||||||
appState.dragState.value.isDraggingNode = false;
|
if (dragState.dragElement) {
|
||||||
appState.dragState.value.dragNodeId = null;
|
dragState.dragElement.classList.remove('dragging');
|
||||||
|
|
||||||
// 最终更新连接线
|
|
||||||
updateConnections();
|
|
||||||
|
|
||||||
// 移除全局事件监听
|
|
||||||
document.removeEventListener('mousemove', onNodeDrag);
|
|
||||||
document.removeEventListener('mouseup', onNodeDragEnd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dragState.isDragging = false;
|
||||||
|
dragState.dragNode = null;
|
||||||
|
dragState.dragElement = null;
|
||||||
|
|
||||||
|
document.removeEventListener('mousemove', onNodeDrag);
|
||||||
|
document.removeEventListener('mouseup', onNodeDragEnd);
|
||||||
|
|
||||||
|
connectionManager.updateConnections();
|
||||||
|
dragState.updateCounter = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 画布操作功能
|
|
||||||
const onCanvasWheel = (event: WheelEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const zoomSpeed = 0.1;
|
|
||||||
const delta = event.deltaY > 0 ? -zoomSpeed : zoomSpeed;
|
|
||||||
const newZoom = Math.max(0.1, Math.min(3, appState.zoomLevel.value + delta));
|
|
||||||
|
|
||||||
appState.zoomLevel.value = newZoom;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCanvasMouseDown = (event: MouseEvent) => {
|
|
||||||
// 只在空白区域开始画布拖拽
|
|
||||||
if (event.target === event.currentTarget) {
|
|
||||||
appState.dragState.value.isDraggingCanvas = true;
|
|
||||||
appState.dragState.value.dragStartX = event.clientX;
|
|
||||||
appState.dragState.value.dragStartY = event.clientY;
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', onCanvasMouseMove);
|
|
||||||
document.addEventListener('mouseup', onCanvasMouseUp);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCanvasMouseMove = (event: MouseEvent) => {
|
|
||||||
if (appState.dragState.value.isDraggingCanvas) {
|
|
||||||
const deltaX = event.clientX - appState.dragState.value.dragStartX;
|
|
||||||
const deltaY = event.clientY - appState.dragState.value.dragStartY;
|
|
||||||
|
|
||||||
appState.panX.value += deltaX;
|
|
||||||
appState.panY.value += deltaY;
|
|
||||||
|
|
||||||
appState.dragState.value.dragStartX = event.clientX;
|
|
||||||
appState.dragState.value.dragStartY = event.clientY;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCanvasMouseUp = (event: MouseEvent) => {
|
|
||||||
if (appState.dragState.value.isDraggingCanvas) {
|
|
||||||
appState.dragState.value.isDraggingCanvas = false;
|
|
||||||
|
|
||||||
document.removeEventListener('mousemove', onCanvasMouseMove);
|
|
||||||
document.removeEventListener('mouseup', onCanvasMouseUp);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 缩放控制
|
|
||||||
const zoomIn = () => {
|
|
||||||
appState.zoomLevel.value = Math.min(3, appState.zoomLevel.value + 0.1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomOut = () => {
|
|
||||||
appState.zoomLevel.value = Math.max(0.1, appState.zoomLevel.value - 0.1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetZoom = () => {
|
|
||||||
appState.zoomLevel.value = 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const centerView = () => {
|
|
||||||
appState.panX.value = 0;
|
|
||||||
appState.panY.value = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 安装处理
|
|
||||||
const handleInstall = () => {
|
const handleInstall = () => {
|
||||||
// 这里应该调用installation中的安装方法
|
installation.handleInstall();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 生命周期管理
|
// 组件挂载时初始化连接
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 初始化连接线
|
// 延迟一下确保 DOM 已经渲染
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
updateConnections();
|
connectionManager.updateConnections();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
// 清理事件监听器
|
|
||||||
cancelConnection();
|
|
||||||
document.removeEventListener('mousemove', onNodeDrag);
|
document.removeEventListener('mousemove', onNodeDrag);
|
||||||
document.removeEventListener('mouseup', onNodeDragEnd);
|
document.removeEventListener('mouseup', onNodeDragEnd);
|
||||||
document.removeEventListener('mousemove', onCanvasMouseMove);
|
|
||||||
document.removeEventListener('mouseup', onCanvasMouseUp);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 解构出所有需要的方法,避免命名冲突
|
|
||||||
const {
|
|
||||||
filteredCompositeNodes,
|
|
||||||
filteredDecoratorNodes,
|
|
||||||
filteredActionNodes,
|
|
||||||
filteredConditionNodes,
|
|
||||||
filteredECSNodes,
|
|
||||||
selectedNode,
|
|
||||||
rootNode,
|
|
||||||
installStatusClass,
|
|
||||||
installStatusText,
|
|
||||||
validationResult,
|
|
||||||
exportedCode,
|
|
||||||
gridStyle
|
|
||||||
} = computedProps;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// DOM refs
|
|
||||||
canvasAreaRef,
|
canvasAreaRef,
|
||||||
svgRef,
|
svgRef,
|
||||||
|
|
||||||
// 状态
|
|
||||||
...appState,
|
...appState,
|
||||||
connectionState,
|
...computedProps,
|
||||||
|
|
||||||
// 计算属性 - 显式导出,避免命名冲突
|
|
||||||
filteredCompositeNodes,
|
|
||||||
filteredDecoratorNodes,
|
|
||||||
filteredActionNodes,
|
|
||||||
filteredConditionNodes,
|
|
||||||
filteredECSNodes,
|
|
||||||
selectedNode,
|
|
||||||
rootNode,
|
|
||||||
installStatusClass,
|
|
||||||
installStatusText,
|
|
||||||
validationResult,
|
|
||||||
exportedCode,
|
|
||||||
gridStyle,
|
|
||||||
|
|
||||||
// 连线方法
|
|
||||||
startConnection,
|
|
||||||
cancelConnection,
|
|
||||||
updateConnections,
|
|
||||||
onPortHover,
|
|
||||||
onPortLeave,
|
|
||||||
isValidConnectionTarget,
|
|
||||||
|
|
||||||
// 节点拖拽
|
|
||||||
startNodeDrag,
|
|
||||||
|
|
||||||
// 画布操作
|
|
||||||
onCanvasWheel,
|
|
||||||
onCanvasMouseDown,
|
|
||||||
onCanvasMouseMove,
|
|
||||||
onCanvasMouseUp,
|
|
||||||
|
|
||||||
// 缩放控制
|
|
||||||
zoomIn,
|
|
||||||
zoomOut,
|
|
||||||
resetZoom,
|
|
||||||
centerView,
|
|
||||||
|
|
||||||
// 其他功能方法
|
|
||||||
...nodeOps,
|
...nodeOps,
|
||||||
|
...fileOps,
|
||||||
...codeGen,
|
...codeGen,
|
||||||
...installation,
|
...installation,
|
||||||
...fileOps,
|
handleInstall,
|
||||||
|
connectionState,
|
||||||
|
...connectionManager,
|
||||||
|
...canvasManager,
|
||||||
|
...nodeDisplay,
|
||||||
|
startNodeDrag,
|
||||||
|
dragState
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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
|
rootNode: () => TreeNode | null
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// TypeScript代码生成
|
// 生成行为树配置JSON
|
||||||
const generateTypeScriptCode = (): string => {
|
const generateBehaviorTreeConfig = () => {
|
||||||
const imports = getRequiredImports();
|
|
||||||
const root = rootNode();
|
const root = rootNode();
|
||||||
|
|
||||||
if (!root) {
|
if (!root) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: "1.0.0",
|
||||||
|
type: "behavior-tree",
|
||||||
|
metadata: {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
hasECSNodes: hasECSNodes(),
|
||||||
|
nodeCount: treeNodes.value.length
|
||||||
|
},
|
||||||
|
tree: generateNodeConfig(root)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成可读的配置JSON字符串
|
||||||
|
const generateConfigJSON = (): string => {
|
||||||
|
const config = generateBehaviorTreeConfig();
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
return '// 请先添加根节点';
|
return '// 请先添加根节点';
|
||||||
}
|
}
|
||||||
|
|
||||||
const importsCode = imports.map(imp => `import { ${imp} } from '@esengine/ai';`).join('\n');
|
return JSON.stringify(config, null, 2);
|
||||||
const treeCode = generateNodeCode(root);
|
|
||||||
|
|
||||||
return `${importsCode}
|
|
||||||
|
|
||||||
// 自动生成的行为树代码
|
|
||||||
export function createBehaviorTree() {
|
|
||||||
return ${treeCode};
|
|
||||||
}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRequiredImports = (): string[] => {
|
// 生成TypeScript构建代码(用于运行时从配置创建行为树)
|
||||||
const imports = new Set<string>();
|
const generateTypeScriptCode = (): string => {
|
||||||
|
const config = generateBehaviorTreeConfig();
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return '// 请先添加根节点';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { behaviorTreeImports, ecsImports } = getRequiredImports();
|
||||||
|
|
||||||
|
let importsCode = '';
|
||||||
|
if (behaviorTreeImports.length > 0) {
|
||||||
|
importsCode += `import { ${behaviorTreeImports.join(', ')}, BehaviorTreeBuilder } from '@esengine/ai';\n`;
|
||||||
|
}
|
||||||
|
if (ecsImports.length > 0) {
|
||||||
|
importsCode += `import { ${ecsImports.join(', ')} } from '@esengine/ecs-framework';\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextType = hasECSNodes() ? 'Entity' : 'any';
|
||||||
|
const configString = JSON.stringify(config, null, 4);
|
||||||
|
|
||||||
|
return `${importsCode}
|
||||||
|
// 行为树配置
|
||||||
|
const behaviorTreeConfig = ${configString};
|
||||||
|
|
||||||
|
// 从配置创建行为树
|
||||||
|
export function createBehaviorTree<T extends ${contextType}>(context?: T): BehaviorTree<T> {
|
||||||
|
return BehaviorTreeBuilder.fromConfig<T>(behaviorTreeConfig, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接导出配置(用于序列化保存)
|
||||||
|
export const config = behaviorTreeConfig;`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRequiredImports = (): { behaviorTreeImports: string[], ecsImports: string[] } => {
|
||||||
|
const behaviorTreeImports = new Set<string>();
|
||||||
|
const ecsImports = new Set<string>();
|
||||||
|
|
||||||
|
// 总是需要这些基础类
|
||||||
|
behaviorTreeImports.add('BehaviorTree');
|
||||||
|
behaviorTreeImports.add('TaskStatus');
|
||||||
|
|
||||||
treeNodes.value.forEach(node => {
|
treeNodes.value.forEach(node => {
|
||||||
const template = nodeTemplates.value.find(t => t.className === node.type || t.type === node.type);
|
const template = nodeTemplates.value.find(t => t.className === node.type || t.type === node.type);
|
||||||
if (template?.className) {
|
if (template?.className) {
|
||||||
imports.add(template.className);
|
if (template.namespace?.includes('ecs-integration')) {
|
||||||
|
behaviorTreeImports.add(template.className);
|
||||||
|
ecsImports.add('Entity');
|
||||||
|
ecsImports.add('Component');
|
||||||
|
} else {
|
||||||
|
behaviorTreeImports.add(template.className);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(imports);
|
return {
|
||||||
|
behaviorTreeImports: Array.from(behaviorTreeImports),
|
||||||
|
ecsImports: Array.from(ecsImports)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasECSNodes = (): boolean => {
|
||||||
|
return treeNodes.value.some(node => {
|
||||||
|
const template = nodeTemplates.value.find(t => t.className === node.type || t.type === node.type);
|
||||||
|
return template?.namespace?.includes('ecs-integration');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成节点配置对象
|
||||||
|
const generateNodeConfig = (node: TreeNode): any => {
|
||||||
|
const template = nodeTemplates.value.find(t => t.className === node.type || t.type === node.type);
|
||||||
|
|
||||||
|
if (!template || !template.className) {
|
||||||
|
return {
|
||||||
|
type: node.type,
|
||||||
|
error: "未知节点类型"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeConfig: any = {
|
||||||
|
id: node.id,
|
||||||
|
type: template.className,
|
||||||
|
namespace: template.namespace || 'behaviourTree',
|
||||||
|
properties: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理节点属性
|
||||||
|
if (node.properties) {
|
||||||
|
Object.entries(node.properties).forEach(([key, prop]) => {
|
||||||
|
if (prop.value !== undefined && prop.value !== '') {
|
||||||
|
nodeConfig.properties[key] = {
|
||||||
|
type: prop.type,
|
||||||
|
value: prop.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理子节点
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
nodeConfig.children = node.children
|
||||||
|
.map(childId => getNodeByIdLocal(childId))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(child => generateNodeConfig(child!));
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodeConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateNodeCode = (node: TreeNode, indent: number = 0): string => {
|
const generateNodeCode = (node: TreeNode, indent: number = 0): string => {
|
||||||
const spaces = ' '.repeat(indent);
|
const spaces = ' '.repeat(indent);
|
||||||
const template = nodeTemplates.value.find(t => t.className === node.type);
|
const template = nodeTemplates.value.find(t => t.className === node.type || t.type === node.type);
|
||||||
|
|
||||||
if (!template) {
|
if (!template || !template.className) {
|
||||||
return `${spaces}// 未知节点类型: ${node.type}`;
|
return `${spaces}// 未知节点类型: ${node.type}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let code = `${spaces}new ${template.className}(`;
|
let code = `${spaces}new ${template.className}(`;
|
||||||
|
|
||||||
// 构造函数参数
|
|
||||||
const params: string[] = [];
|
const params: string[] = [];
|
||||||
|
|
||||||
// 处理属性
|
// 处理特定节点的构造函数参数
|
||||||
if (node.properties && Object.keys(node.properties).length > 0) {
|
if (template.namespace?.includes('ecs-integration')) {
|
||||||
const propsCode: string[] = [];
|
// ECS节点的特殊处理
|
||||||
|
switch (template.className) {
|
||||||
Object.entries(node.properties).forEach(([key, prop]) => {
|
case 'HasComponentCondition':
|
||||||
if (prop.type === 'code' && prop.value) {
|
case 'AddComponentAction':
|
||||||
propsCode.push(`${key}: ${prop.value}`);
|
case 'RemoveComponentAction':
|
||||||
} else if (prop.type === 'string' && prop.value !== undefined) {
|
case 'ModifyComponentAction':
|
||||||
propsCode.push(`${key}: "${prop.value}"`);
|
if (node.properties?.componentType?.value) {
|
||||||
} else if (prop.type === 'number' && prop.value !== undefined) {
|
params.push(node.properties.componentType.value);
|
||||||
propsCode.push(`${key}: ${prop.value}`);
|
}
|
||||||
} else if (prop.type === 'boolean' && prop.value !== undefined) {
|
if (template.className === 'AddComponentAction' && node.properties?.componentFactory?.value) {
|
||||||
propsCode.push(`${key}: ${prop.value}`);
|
params.push(node.properties.componentFactory.value);
|
||||||
} else if (prop.type === 'select' && prop.value !== undefined) {
|
}
|
||||||
propsCode.push(`${key}: "${prop.value}"`);
|
if (template.className === 'ModifyComponentAction' && node.properties?.modifierCode?.value) {
|
||||||
}
|
params.push(node.properties.modifierCode.value);
|
||||||
});
|
}
|
||||||
|
break;
|
||||||
if (propsCode.length > 0) {
|
case 'HasTagCondition':
|
||||||
params.push(`{\n${spaces} ${propsCode.join(',\n' + spaces + ' ')}\n${spaces}}`);
|
if (node.properties?.tag?.value !== undefined) {
|
||||||
|
params.push(node.properties.tag.value.toString());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'IsActiveCondition':
|
||||||
|
if (node.properties?.checkHierarchy?.value !== undefined) {
|
||||||
|
params.push(node.properties.checkHierarchy.value.toString());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'WaitTimeAction':
|
||||||
|
if (node.properties?.waitTime?.value !== undefined) {
|
||||||
|
params.push(node.properties.waitTime.value.toString());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 普通行为树节点的处理
|
||||||
|
switch (template.className) {
|
||||||
|
case 'ExecuteAction':
|
||||||
|
case 'ExecuteActionConditional':
|
||||||
|
if (node.properties?.actionCode?.value || node.properties?.conditionCode?.value) {
|
||||||
|
const code = node.properties.actionCode?.value || node.properties.conditionCode?.value;
|
||||||
|
params.push(code);
|
||||||
|
if (node.properties?.actionName?.value) {
|
||||||
|
params.push(`{ name: "${node.properties.actionName.value}" }`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'WaitAction':
|
||||||
|
if (node.properties?.waitTime?.value !== undefined) {
|
||||||
|
params.push(node.properties.waitTime.value.toString());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'LogAction':
|
||||||
|
if (node.properties?.message?.value) {
|
||||||
|
params.push(`"${node.properties.message.value}"`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Repeater':
|
||||||
|
if (node.properties?.repeatCount?.value !== undefined) {
|
||||||
|
params.push(node.properties.repeatCount.value.toString());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Sequence':
|
||||||
|
case 'Selector':
|
||||||
|
if (node.properties?.abortType?.value && node.properties.abortType.value !== 'None') {
|
||||||
|
params.push(`AbortTypes.${node.properties.abortType.value}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
code += params.join(', ');
|
code += params.join(', ');
|
||||||
|
code += ')';
|
||||||
|
|
||||||
// 子节点
|
// 处理子节点(对于复合节点和装饰器)
|
||||||
if (node.children && node.children.length > 0) {
|
if (template.canHaveChildren && node.children && node.children.length > 0) {
|
||||||
const children = node.children
|
const children = node.children
|
||||||
.map(childId => getNodeByIdLocal(childId))
|
.map(childId => getNodeByIdLocal(childId))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map(child => generateNodeCode(child!, indent + 1));
|
.map(child => generateNodeCode(child!, indent + 1));
|
||||||
|
|
||||||
if (children.length > 0) {
|
if (children.length > 0) {
|
||||||
if (params.length > 0) code += ', ';
|
const className = template.className; // 保存到局部变量
|
||||||
code += '[\n' + children.join(',\n') + '\n' + spaces + ']';
|
if (template.category === 'decorator') {
|
||||||
|
// 装饰器只有一个子节点
|
||||||
|
code = code.slice(0, -1); // 移除最后的 ')'
|
||||||
|
const varName = className.toLowerCase();
|
||||||
|
code += `;\n${spaces}${varName}.child = ${children[0].trim()};\n${spaces}return ${varName}`;
|
||||||
|
} else if (template.category === 'composite') {
|
||||||
|
// 复合节点需要添加子节点
|
||||||
|
code = code.slice(0, -1); // 移除最后的 ')'
|
||||||
|
code += `;\n`;
|
||||||
|
children.forEach(child => {
|
||||||
|
code += `${spaces}${className.toLowerCase()}.addChild(${child.trim()});\n`;
|
||||||
|
});
|
||||||
|
code += `${spaces}return ${className.toLowerCase()}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
code += ')';
|
|
||||||
return code;
|
return code;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 从配置创建行为树节点
|
||||||
|
const createTreeFromConfig = (config: any): TreeNode[] => {
|
||||||
|
if (!config || !config.tree) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: TreeNode[] = [];
|
||||||
|
const processNode = (nodeConfig: any, parent?: TreeNode): TreeNode => {
|
||||||
|
const template = nodeTemplates.value.find(t => t.className === nodeConfig.type);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(`未知节点类型: ${nodeConfig.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const node: TreeNode = {
|
||||||
|
id: nodeConfig.id || generateNodeId(),
|
||||||
|
type: template.type,
|
||||||
|
name: template.name,
|
||||||
|
icon: template.icon,
|
||||||
|
description: template.description,
|
||||||
|
canHaveChildren: template.canHaveChildren,
|
||||||
|
canHaveParent: template.canHaveParent,
|
||||||
|
x: 400, // 默认在画布中心
|
||||||
|
y: 100, // 从顶部开始
|
||||||
|
properties: {},
|
||||||
|
children: [],
|
||||||
|
parent: parent?.id // 设置父节点ID
|
||||||
|
};
|
||||||
|
|
||||||
|
// 恢复属性
|
||||||
|
if (nodeConfig.properties) {
|
||||||
|
Object.entries(nodeConfig.properties).forEach(([key, propConfig]: [string, any]) => {
|
||||||
|
if (template.properties?.[key]) {
|
||||||
|
node.properties![key] = {
|
||||||
|
...template.properties[key],
|
||||||
|
value: propConfig.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.push(node);
|
||||||
|
|
||||||
|
// 处理子节点
|
||||||
|
if (nodeConfig.children && Array.isArray(nodeConfig.children)) {
|
||||||
|
nodeConfig.children.forEach((childConfig: any) => {
|
||||||
|
const childNode = processNode(childConfig, node);
|
||||||
|
node.children!.push(childNode.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
processNode(config.tree);
|
||||||
|
return nodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成唯一节点ID
|
||||||
|
const generateNodeId = (): string => {
|
||||||
|
return 'node_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
generateBehaviorTreeConfig,
|
||||||
|
generateConfigJSON,
|
||||||
generateTypeScriptCode,
|
generateTypeScriptCode,
|
||||||
generateNodeCode,
|
generateNodeCode,
|
||||||
|
generateNodeConfig,
|
||||||
|
createTreeFromConfig,
|
||||||
getRequiredImports
|
getRequiredImports
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Ref } from 'vue';
|
import { Ref, computed } from 'vue';
|
||||||
import { TreeNode } from '../types';
|
import { TreeNode } from '../types';
|
||||||
import { NodeTemplate } from '../data/nodeTemplates';
|
import { NodeTemplate } from '../data/nodeTemplates';
|
||||||
import { getRootNode } from '../utils/nodeUtils';
|
import { getRootNode } from '../utils/nodeUtils';
|
||||||
import { getInstallStatusText, getInstallStatusClass } from '../utils/installUtils';
|
import { getInstallStatusText, getInstallStatusClass } from '../utils/installUtils';
|
||||||
import { generateCode } from '../utils/codeGenerator';
|
|
||||||
import { getGridStyle } from '../utils/canvasUtils';
|
import { getGridStyle } from '../utils/canvasUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +21,11 @@ export function useComputedProperties(
|
|||||||
panX: Ref<number>,
|
panX: Ref<number>,
|
||||||
panY: Ref<number>,
|
panY: Ref<number>,
|
||||||
zoomLevel: Ref<number>,
|
zoomLevel: Ref<number>,
|
||||||
getNodeByIdLocal: (id: string) => TreeNode | undefined
|
getNodeByIdLocal: (id: string) => TreeNode | undefined,
|
||||||
|
codeGeneration?: {
|
||||||
|
generateConfigJSON: () => string;
|
||||||
|
generateTypeScriptCode: () => string;
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
// 过滤节点
|
// 过滤节点
|
||||||
const filteredCompositeNodes = () => {
|
const filteredCompositeNodes = () => {
|
||||||
@@ -60,10 +63,14 @@ export function useComputedProperties(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 选中的节点
|
// 选中的节点 - 使用computed确保响应式更新
|
||||||
const selectedNode = () => {
|
const selectedNode = computed(() => {
|
||||||
return selectedNodeId.value ? getNodeByIdLocal(selectedNodeId.value) : null;
|
if (!selectedNodeId.value) return null;
|
||||||
};
|
|
||||||
|
// 直接从treeNodes数组中查找,确保获取最新的节点状态
|
||||||
|
const node = treeNodes.value.find(n => n.id === selectedNodeId.value);
|
||||||
|
return node || null;
|
||||||
|
});
|
||||||
|
|
||||||
// 根节点
|
// 根节点
|
||||||
const rootNode = () => {
|
const rootNode = () => {
|
||||||
@@ -98,8 +105,16 @@ export function useComputedProperties(
|
|||||||
|
|
||||||
// 导出代码
|
// 导出代码
|
||||||
const exportedCode = () => {
|
const exportedCode = () => {
|
||||||
|
if (!codeGeneration) {
|
||||||
|
return '// 代码生成器未初始化';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return generateCode(treeNodes.value, exportFormat.value);
|
if (exportFormat.value === 'json') {
|
||||||
|
return codeGeneration.generateConfigJSON();
|
||||||
|
} else {
|
||||||
|
return codeGeneration.generateTypeScriptCode();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return `// 代码生成失败: ${error}`;
|
return `// 代码生成失败: ${error}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import { TreeNode, Connection } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,28 +9,417 @@ export function useFileOperations(
|
|||||||
selectedNodeId: Ref<string | null>,
|
selectedNodeId: Ref<string | null>,
|
||||||
connections: Ref<Connection[]>,
|
connections: Ref<Connection[]>,
|
||||||
tempConnection: Ref<{ path: string }>,
|
tempConnection: Ref<{ path: string }>,
|
||||||
showExportModal: Ref<boolean>
|
showExportModal: Ref<boolean>,
|
||||||
|
codeGeneration?: {
|
||||||
|
createTreeFromConfig: (config: any) => TreeNode[];
|
||||||
|
},
|
||||||
|
updateConnections?: () => void
|
||||||
) {
|
) {
|
||||||
|
// 跟踪未保存状态
|
||||||
|
const hasUnsavedChanges = ref(false);
|
||||||
|
const lastSavedState = ref<string>('');
|
||||||
|
const currentFileName = ref('');
|
||||||
|
|
||||||
|
// 监听树结构变化来更新未保存状态
|
||||||
|
const updateUnsavedStatus = () => {
|
||||||
|
const currentState = JSON.stringify({
|
||||||
|
nodes: treeNodes.value,
|
||||||
|
connections: connections.value
|
||||||
|
});
|
||||||
|
hasUnsavedChanges.value = currentState !== lastSavedState.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听变化
|
||||||
|
watch([treeNodes, connections], updateUnsavedStatus, { deep: true });
|
||||||
|
|
||||||
|
// 标记为已保存
|
||||||
|
const markAsSaved = () => {
|
||||||
|
const currentState = JSON.stringify({
|
||||||
|
nodes: treeNodes.value,
|
||||||
|
connections: connections.value
|
||||||
|
});
|
||||||
|
lastSavedState.value = currentState;
|
||||||
|
hasUnsavedChanges.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查是否需要保存的通用方法
|
||||||
|
const checkUnsavedChanges = (): Promise<boolean> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!hasUnsavedChanges.value) {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = confirm(
|
||||||
|
'当前行为树有未保存的更改,是否要保存?\n\n' +
|
||||||
|
'点击"确定"保存更改\n' +
|
||||||
|
'点击"取消"丢弃更改\n' +
|
||||||
|
'点击"X"取消操作'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// 用户选择保存
|
||||||
|
saveBehaviorTree().then(() => {
|
||||||
|
resolve(true);
|
||||||
|
}).catch(() => {
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 用户选择丢弃更改
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出行为树数据
|
||||||
|
const exportBehaviorTreeData = () => {
|
||||||
|
return {
|
||||||
|
nodes: treeNodes.value,
|
||||||
|
connections: connections.value,
|
||||||
|
metadata: {
|
||||||
|
name: currentFileName.value || 'untitled',
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
version: '1.0'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// 工具栏操作
|
// 工具栏操作
|
||||||
const newBehaviorTree = () => {
|
const newBehaviorTree = async () => {
|
||||||
|
const canProceed = await checkUnsavedChanges();
|
||||||
|
if (canProceed) {
|
||||||
treeNodes.value = [];
|
treeNodes.value = [];
|
||||||
selectedNodeId.value = null;
|
selectedNodeId.value = null;
|
||||||
connections.value = [];
|
connections.value = [];
|
||||||
tempConnection.value.path = '';
|
tempConnection.value.path = '';
|
||||||
|
currentFileName.value = '';
|
||||||
|
markAsSaved(); // 新建后标记为已保存状态
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveBehaviorTree = () => {
|
// 保存行为树
|
||||||
// TODO: 实现保存功能
|
const saveBehaviorTree = async (): Promise<boolean> => {
|
||||||
console.log('保存行为树');
|
console.log('=== 开始保存行为树 ===');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = exportBehaviorTreeData();
|
||||||
|
const jsonString = JSON.stringify(data, null, 2);
|
||||||
|
console.log('数据准备完成,JSON长度:', jsonString.length);
|
||||||
|
|
||||||
|
// 使用 HTML input 替代 prompt(因为 prompt 在 Cocos Creator 扩展中不支持)
|
||||||
|
const fileName = await getFileNameFromUser();
|
||||||
|
if (!fileName) {
|
||||||
|
console.log('❌ 用户取消了保存操作');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✓ 用户输入文件名:', fileName);
|
||||||
|
|
||||||
|
// 检测是否在Cocos Creator环境中
|
||||||
|
if (typeof Editor !== 'undefined' && typeof (window as any).sendToMain === 'function') {
|
||||||
|
console.log('✓ 使用Cocos Creator保存方式');
|
||||||
|
|
||||||
|
try {
|
||||||
|
(window as any).sendToMain('create-behavior-tree-from-editor', {
|
||||||
|
fileName: fileName + '.json',
|
||||||
|
content: jsonString,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✓ 保存消息已发送到主进程');
|
||||||
|
|
||||||
|
// 更新当前文件名并标记为已保存
|
||||||
|
currentFileName.value = fileName;
|
||||||
|
markAsSaved();
|
||||||
|
|
||||||
|
// 用户反馈
|
||||||
|
showMessage(`保存成功!文件名: ${fileName}.json`, 'success');
|
||||||
|
|
||||||
|
console.log('✅ 保存操作完成');
|
||||||
|
return true;
|
||||||
|
} catch (sendError) {
|
||||||
|
console.error('❌ 发送消息时出错:', sendError);
|
||||||
|
showMessage('保存失败: ' + sendError, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('✓ 使用浏览器下载保存方式');
|
||||||
|
|
||||||
|
// 在浏览器环境中使用下载方式
|
||||||
|
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${fileName}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
// 标记为已保存
|
||||||
|
currentFileName.value = fileName;
|
||||||
|
markAsSaved();
|
||||||
|
|
||||||
|
console.log('✅ 文件下载保存成功');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 保存过程中发生错误:', error);
|
||||||
|
showMessage('保存失败: ' + error, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadBehaviorTree = () => {
|
// 使用 HTML input 获取文件名(替代 prompt)
|
||||||
// TODO: 实现加载功能
|
const getFileNameFromUser = (): Promise<string | null> => {
|
||||||
console.log('加载行为树');
|
return new Promise((resolve) => {
|
||||||
|
// 创建模态对话框
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 10000;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dialog = document.createElement('div');
|
||||||
|
dialog.style.cssText = `
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
min-width: 300px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
dialog.innerHTML = `
|
||||||
|
<h3 style="margin: 0 0 15px 0; color: #ffffff;">保存行为树</h3>
|
||||||
|
<p style="margin: 0 0 15px 0; color: #cccccc;">请输入文件名(不含扩展名):</p>
|
||||||
|
<input type="text" id="filename-input" value="${currentFileName.value || 'behavior_tree'}"
|
||||||
|
style="width: 100%; padding: 8px; border: 1px solid #555; background: #1a1a1a; color: #ffffff; border-radius: 4px; margin-bottom: 15px;">
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<button id="cancel-btn" style="padding: 8px 16px; margin-right: 8px; background: #555; color: #fff; border: none; border-radius: 4px; cursor: pointer;">取消</button>
|
||||||
|
<button id="save-btn" style="padding: 8px 16px; background: #007acc; color: #fff; border: none; border-radius: 4px; cursor: pointer;">保存</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
overlay.appendChild(dialog);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const input = dialog.querySelector('#filename-input') as HTMLInputElement;
|
||||||
|
const saveBtn = dialog.querySelector('#save-btn') as HTMLButtonElement;
|
||||||
|
const cancelBtn = dialog.querySelector('#cancel-btn') as HTMLButtonElement;
|
||||||
|
|
||||||
|
// 聚焦并选中文本
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
// 事件处理
|
||||||
|
const cleanup = () => {
|
||||||
|
document.body.removeChild(overlay);
|
||||||
|
};
|
||||||
|
|
||||||
|
saveBtn.onclick = () => {
|
||||||
|
const fileName = input.value.trim();
|
||||||
|
cleanup();
|
||||||
|
resolve(fileName || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelBtn.onclick = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 回车键保存
|
||||||
|
input.onkeydown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const fileName = input.value.trim();
|
||||||
|
cleanup();
|
||||||
|
resolve(fileName || null);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportCode = () => {
|
// 显示消息提示
|
||||||
|
const showMessage = (message: string, type: 'success' | 'error' = 'success') => {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: ${type === 'success' ? '#4caf50' : '#f44336'};
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 10001;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
`;
|
||||||
|
toast.textContent = message;
|
||||||
|
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
// 动画显示
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '1';
|
||||||
|
toast.style.transform = 'translateX(0)';
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// 3秒后自动消失
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
toast.style.transform = 'translateX(100%)';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.body.contains(toast)) {
|
||||||
|
document.body.removeChild(toast);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成当前行为树的配置
|
||||||
|
const generateCurrentConfig = () => {
|
||||||
|
if (treeNodes.value.length === 0) return null;
|
||||||
|
|
||||||
|
const rootNode = treeNodes.value.find(node =>
|
||||||
|
!treeNodes.value.some(otherNode =>
|
||||||
|
otherNode.children?.includes(node.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rootNode) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: "1.0.0",
|
||||||
|
type: "behavior-tree",
|
||||||
|
metadata: {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
nodeCount: treeNodes.value.length
|
||||||
|
},
|
||||||
|
tree: generateNodeConfig(rootNode)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简化的节点配置生成(用于文件保存)
|
||||||
|
const generateNodeConfig = (node: TreeNode): any => {
|
||||||
|
const config: any = {
|
||||||
|
id: node.id,
|
||||||
|
type: node.type,
|
||||||
|
namespace: getNodeNamespace(node.type),
|
||||||
|
properties: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理节点属性
|
||||||
|
if (node.properties) {
|
||||||
|
Object.entries(node.properties).forEach(([key, prop]) => {
|
||||||
|
if (prop.value !== undefined && prop.value !== '') {
|
||||||
|
config.properties[key] = {
|
||||||
|
type: prop.type,
|
||||||
|
value: prop.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理子节点
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
config.children = node.children
|
||||||
|
.map(childId => treeNodes.value.find(n => n.id === childId))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(child => generateNodeConfig(child!));
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取节点命名空间
|
||||||
|
const getNodeNamespace = (nodeType: string): string => {
|
||||||
|
// ECS节点
|
||||||
|
if (['has-component', 'add-component', 'remove-component', 'modify-component',
|
||||||
|
'has-tag', 'is-active', 'wait-time', 'destroy-entity'].includes(nodeType)) {
|
||||||
|
return 'ecs-integration/behaviors';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复合节点
|
||||||
|
if (['sequence', 'selector', 'parallel', 'parallel-selector',
|
||||||
|
'random-selector', 'random-sequence'].includes(nodeType)) {
|
||||||
|
return 'behaviourTree/composites';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 装饰器
|
||||||
|
if (['repeater', 'inverter', 'always-fail', 'always-succeed',
|
||||||
|
'until-fail', 'until-success'].includes(nodeType)) {
|
||||||
|
return 'behaviourTree/decorators';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动作节点
|
||||||
|
if (['execute-action', 'log-action', 'wait-action'].includes(nodeType)) {
|
||||||
|
return 'behaviourTree/actions';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 条件节点
|
||||||
|
if (['execute-conditional'].includes(nodeType)) {
|
||||||
|
return 'behaviourTree/conditionals';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'behaviourTree';
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadBehaviorTree = async () => {
|
||||||
|
const canProceed = await checkUnsavedChanges();
|
||||||
|
if (!canProceed) return;
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.json,.bt';
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
try {
|
||||||
|
const configText = event.target?.result as string;
|
||||||
|
const config = JSON.parse(configText);
|
||||||
|
|
||||||
|
if (codeGeneration) {
|
||||||
|
const newNodes = codeGeneration.createTreeFromConfig(config);
|
||||||
|
treeNodes.value = newNodes;
|
||||||
|
selectedNodeId.value = null;
|
||||||
|
connections.value = [];
|
||||||
|
tempConnection.value.path = '';
|
||||||
|
markAsSaved(); // 加载后标记为已保存状态
|
||||||
|
console.log('行为树配置加载成功');
|
||||||
|
if (updateConnections) {
|
||||||
|
updateConnections();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('代码生成器未初始化');
|
||||||
|
alert('代码生成器未初始化');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载行为树配置失败:', error);
|
||||||
|
alert('配置文件格式错误');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportConfig = () => {
|
||||||
showExportModal.value = true;
|
showExportModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,10 +448,12 @@ export function useFileOperations(
|
|||||||
newBehaviorTree,
|
newBehaviorTree,
|
||||||
saveBehaviorTree,
|
saveBehaviorTree,
|
||||||
loadBehaviorTree,
|
loadBehaviorTree,
|
||||||
exportCode,
|
exportConfig,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
saveToFile,
|
saveToFile,
|
||||||
autoLayout,
|
autoLayout,
|
||||||
validateTree
|
validateTree,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
markAsSaved
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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 { TreeNode, Connection } from '../types';
|
||||||
import { NodeTemplate } from '../data/nodeTemplates';
|
import { NodeTemplate } from '../data/nodeTemplates';
|
||||||
import { createNodeFromTemplate } from '../utils/nodeUtils';
|
import { createNodeFromTemplate } from '../utils/nodeUtils';
|
||||||
@@ -14,7 +14,8 @@ export function useNodeOperations(
|
|||||||
panX: Ref<number>,
|
panX: Ref<number>,
|
||||||
panY: Ref<number>,
|
panY: Ref<number>,
|
||||||
zoomLevel: Ref<number>,
|
zoomLevel: Ref<number>,
|
||||||
getNodeByIdLocal: (id: string) => TreeNode | undefined
|
getNodeByIdLocal: (id: string) => TreeNode | undefined,
|
||||||
|
updateConnections?: () => void
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// 获取相对于画布的坐标(用于节点拖放等操作)
|
// 获取相对于画布的坐标(用于节点拖放等操作)
|
||||||
@@ -94,31 +95,73 @@ export function useNodeOperations(
|
|||||||
if (selectedNodeId.value === nodeId) {
|
if (selectedNodeId.value === nodeId) {
|
||||||
selectedNodeId.value = null;
|
selectedNodeId.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新连接线
|
||||||
|
if (updateConnections) {
|
||||||
|
updateConnections();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 通用的属性更新方法
|
||||||
|
const setNestedProperty = (obj: any, path: string, value: any) => {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current = obj;
|
||||||
|
|
||||||
|
// 导航到目标属性的父对象
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
const key = keys[i];
|
||||||
|
if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
current = current[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置最终值
|
||||||
|
const finalKey = keys[keys.length - 1];
|
||||||
|
current[finalKey] = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 节点属性更新
|
// 节点属性更新
|
||||||
const updateNodeProperty = (path: string, value: any) => {
|
const updateNodeProperty = (path: string, value: any) => {
|
||||||
|
console.log('updateNodeProperty called:', path, value);
|
||||||
const node = selectedNodeId.value ? getNodeByIdLocal(selectedNodeId.value) : null;
|
const node = selectedNodeId.value ? getNodeByIdLocal(selectedNodeId.value) : null;
|
||||||
if (!node) return;
|
if (!node) {
|
||||||
|
console.log('No selected node found');
|
||||||
// 确保 properties 对象存在
|
return;
|
||||||
if (!node.properties) {
|
|
||||||
node.properties = {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = path.split('.');
|
console.log('Current node before update:', JSON.stringify(node, null, 2));
|
||||||
let target: any = node.properties;
|
|
||||||
|
|
||||||
// 导航到目标对象,如果中间对象不存在则创建
|
// 使用通用方法更新属性
|
||||||
for (let i = 0; i < keys.length - 1; i++) {
|
setNestedProperty(node, path, value);
|
||||||
if (!target[keys[i]] || typeof target[keys[i]] !== 'object') {
|
|
||||||
target[keys[i]] = {};
|
console.log(`Updated property ${path} to:`, value);
|
||||||
}
|
console.log('Updated node after change:', JSON.stringify(node, null, 2));
|
||||||
target = target[keys[i]];
|
|
||||||
|
// 强制触发响应式更新 - 创建新数组来强制Vue检测变化
|
||||||
|
const nodeIndex = treeNodes.value.findIndex(n => n.id === node.id);
|
||||||
|
if (nodeIndex > -1) {
|
||||||
|
// 创建新的节点数组,确保Vue能检测到变化
|
||||||
|
const newNodes = [...treeNodes.value];
|
||||||
|
newNodes[nodeIndex] = { ...node }; // 创建节点副本确保响应式更新
|
||||||
|
treeNodes.value = newNodes;
|
||||||
|
|
||||||
|
console.log('Triggered reactive update - replaced array');
|
||||||
|
|
||||||
|
// 验证更新是否成功
|
||||||
|
nextTick(() => {
|
||||||
|
const verifyNode = treeNodes.value.find(n => n.id === node.id);
|
||||||
|
console.log('Verification - node after update:', JSON.stringify(verifyNode, null, 2));
|
||||||
|
|
||||||
|
// 验证属性值
|
||||||
|
const pathParts = path.split('.');
|
||||||
|
let checkValue: any = verifyNode;
|
||||||
|
for (const part of pathParts) {
|
||||||
|
checkValue = checkValue?.[part];
|
||||||
|
}
|
||||||
|
console.log(`Verification - final value at ${path}:`, checkValue);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置最终值
|
|
||||||
target[keys[keys.length - 1]] = value;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -386,6 +386,13 @@ export const nodeTemplates: NodeTemplate[] = [
|
|||||||
value: 'Component',
|
value: 'Component',
|
||||||
description: '要添加的组件类型名称',
|
description: '要添加的组件类型名称',
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
componentFactory: {
|
||||||
|
name: '组件工厂函数',
|
||||||
|
type: 'code',
|
||||||
|
value: '() => new Component()',
|
||||||
|
description: '创建组件实例的函数(可选)',
|
||||||
|
required: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ module.exports = Editor.Panel.define({
|
|||||||
methods: {
|
methods: {
|
||||||
sendToMain(message: string, ...args: any[]) {
|
sendToMain(message: string, ...args: any[]) {
|
||||||
Editor.Message.send('cocos-ecs-extension', message, ...args);
|
Editor.Message.send('cocos-ecs-extension', message, ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
loadBehaviorTreeFile(fileData: any) {
|
||||||
|
console.log('Loading behavior tree file:', fileData);
|
||||||
|
|
||||||
|
// 通知编辑器组件加载文件
|
||||||
|
if (this.$.app) {
|
||||||
|
const event = new CustomEvent('load-behavior-tree-file', {
|
||||||
|
detail: fileData
|
||||||
|
});
|
||||||
|
this.$.app.dispatchEvent(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -29,6 +41,9 @@ module.exports = Editor.Panel.define({
|
|||||||
const app = createApp({});
|
const app = createApp({});
|
||||||
app.config.compilerOptions.isCustomElement = (tag) => tag.startsWith('ui-');
|
app.config.compilerOptions.isCustomElement = (tag) => tag.startsWith('ui-');
|
||||||
|
|
||||||
|
// 暴露发送消息到主进程的方法
|
||||||
|
(window as any).sendToMain = this.sendToMain.bind(this);
|
||||||
|
|
||||||
// 树节点组件
|
// 树节点组件
|
||||||
app.component('tree-node-item', defineComponent({
|
app.component('tree-node-item', defineComponent({
|
||||||
props: ['node', 'level', 'getNodeByIdLocal'],
|
props: ['node', 'level', 'getNodeByIdLocal'],
|
||||||
|
|||||||
@@ -57,3 +57,12 @@ export interface CanvasCoordinates {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConnectionState {
|
||||||
|
isConnecting: boolean;
|
||||||
|
startNodeId: string | null;
|
||||||
|
startPortType: 'input' | 'output' | null;
|
||||||
|
currentMousePos: { x: number; y: number } | null;
|
||||||
|
startPortPos: { x: number; y: number } | null;
|
||||||
|
hoveredPort: { nodeId: string; portType: 'input' | 'output' } | null;
|
||||||
|
}
|
||||||
@@ -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);
|
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unsaved-indicator {
|
||||||
|
color: #ff6b6b;
|
||||||
|
animation: pulse-unsaved 2s infinite;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-unsaved {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-buttons {
|
.toolbar-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -57,6 +68,21 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-btn.has-changes {
|
||||||
|
background: rgba(255, 107, 107, 0.2);
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
animation: glow-save 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-save {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 5px rgba(255, 107, 107, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 15px rgba(255, 107, 107, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-right .install-status {
|
.toolbar-right .install-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -215,7 +241,7 @@
|
|||||||
.canvas-area {
|
.canvas-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 20px 20px, #4a5568 1px, transparent 1px),
|
radial-gradient(circle at 20px 20px, #4a5568 1px, transparent 1px),
|
||||||
linear-gradient(90deg, transparent 19px, #2d3748 20px, transparent 21px),
|
linear-gradient(90deg, transparent 19px, #2d3748 20px, transparent 21px),
|
||||||
@@ -228,6 +254,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-layer {
|
.connection-layer {
|
||||||
@@ -237,6 +264,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-line {
|
.connection-line {
|
||||||
@@ -287,11 +315,17 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
|
overflow: visible;
|
||||||
|
/* 硬件加速优化 */
|
||||||
|
transform: translateZ(0);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
perspective: 1000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-node {
|
.tree-node {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
|
min-height: 80px;
|
||||||
background: linear-gradient(145deg, #2d3748 0%, #1a202c 100%);
|
background: linear-gradient(145deg, #2d3748 0%, #1a202c 100%);
|
||||||
border: 2px solid #4a5568;
|
border: 2px solid #4a5568;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -857,18 +891,21 @@
|
|||||||
border: 2px dashed #667eea;
|
border: 2px dashed #667eea;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 拖动状态样式 */
|
/* 拖动状态样式 - 优化硬件加速 */
|
||||||
.tree-node.dragging {
|
.tree-node.dragging {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
transform: scale(1.02) rotate(1deg);
|
transform: translateZ(0) scale(1.02) rotate(1deg);
|
||||||
box-shadow: 0 12px 30px rgba(0,0,0,0.4);
|
box-shadow: 0 12px 30px rgba(0,0,0,0.4);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
transition: none;
|
transition: none !important;
|
||||||
border-color: #67b7dc;
|
border-color: #67b7dc;
|
||||||
|
/* 强制硬件加速 */
|
||||||
|
transform-origin: center center;
|
||||||
|
perspective: 1000px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 节点悬停时的端口显示优化 */
|
/* 节点悬停时的端口显示优化 */
|
||||||
@@ -900,3 +937,71 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transform: rotate(5deg);
|
transform: rotate(5deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 节点属性预览样式 */
|
||||||
|
.node-properties-preview {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-preview-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-label {
|
||||||
|
color: #a0aec0;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-value {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 不同类型属性的颜色 */
|
||||||
|
.property-value.property-boolean {
|
||||||
|
color: #68d391;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-value.property-number {
|
||||||
|
color: #63b3ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-value.property-select {
|
||||||
|
color: #f6ad55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-value.property-string {
|
||||||
|
color: #cbd5e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-value.property-code {
|
||||||
|
color: #d69e2e;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 适应节点宽度 */
|
||||||
|
.tree-node .node-body {
|
||||||
|
max-width: 146px; /* 节点宽度 - padding */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 当节点有属性时稍微增加高度空间 */
|
||||||
|
.tree-node:has(.node-properties-preview) {
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
<!-- 头部工具栏 -->
|
<!-- 头部工具栏 -->
|
||||||
<div class="header-toolbar">
|
<div class="header-toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<h2>🌳 行为树可视化编辑器</h2>
|
<h2>🌳 行为树可视化编辑器 <span v-if="hasUnsavedChanges" class="unsaved-indicator">●</span></h2>
|
||||||
<div class="toolbar-buttons">
|
<div class="toolbar-buttons">
|
||||||
<button class="tool-btn" @click="newBehaviorTree" title="新建行为树">
|
<button class="tool-btn" @click="newBehaviorTree" title="新建行为树">
|
||||||
<span>📄</span> 新建
|
<span>📄</span> 新建
|
||||||
</button>
|
</button>
|
||||||
<button class="tool-btn" @click="saveBehaviorTree" title="保存行为树">
|
<button class="tool-btn" :class="{ 'has-changes': hasUnsavedChanges }" @click="saveBehaviorTree" title="保存行为树">
|
||||||
<span>💾</span> 保存
|
<span>💾</span> 保存{{ hasUnsavedChanges ? ' *' : '' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="tool-btn" @click="loadBehaviorTree" title="加载行为树">
|
<button class="tool-btn" @click="loadBehaviorTree" title="加载行为树">
|
||||||
<span>📂</span> 加载
|
<span>📂</span> 加载
|
||||||
</button>
|
</button>
|
||||||
<button class="tool-btn" @click="exportCode" title="导出代码">
|
<button class="tool-btn" @click="exportConfig" title="导出配置">
|
||||||
<span>⚡</span> 导出代码
|
<span>⚡</span> 导出配置
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
{
|
{
|
||||||
'node-selected': selectedNodeId === node.id,
|
'node-selected': selectedNodeId === node.id,
|
||||||
'node-error': node.hasError,
|
'node-error': node.hasError,
|
||||||
'dragging': dragState.dragNodeId === node.id
|
'dragging': dragState.dragNode && dragState.dragNode.id === node.id
|
||||||
}
|
}
|
||||||
]"
|
]"
|
||||||
:style="{
|
:style="{
|
||||||
@@ -220,8 +220,22 @@
|
|||||||
<span class="node-title">{{ node.name }}</span>
|
<span class="node-title">{{ node.name }}</span>
|
||||||
<button class="node-delete" @click.stop="deleteNode(node.id)">×</button>
|
<button class="node-delete" @click.stop="deleteNode(node.id)">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="node-body" v-if="node.description">
|
<div class="node-body">
|
||||||
<div class="node-description">{{ node.description }}</div>
|
<div v-if="node.description" class="node-description">{{ node.description }}</div>
|
||||||
|
<!-- 节点属性预览 -->
|
||||||
|
<div v-if="hasVisibleProperties(node)" class="node-properties-preview">
|
||||||
|
<div
|
||||||
|
v-for="(prop, key) in getVisibleProperties(node)"
|
||||||
|
:key="key"
|
||||||
|
class="property-preview-item"
|
||||||
|
:title="prop.name + ': ' + prop.description"
|
||||||
|
>
|
||||||
|
<span class="property-label">{{ prop.name }}:</span>
|
||||||
|
<span class="property-value" :class="'property-' + prop.type">
|
||||||
|
{{ formatPropertyValue(prop) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 输入端口 - 执行流入口 -->
|
<!-- 输入端口 - 执行流入口 -->
|
||||||
<div
|
<div
|
||||||
@@ -276,30 +290,32 @@
|
|||||||
<h3>⚙️ 属性面板</h3>
|
<h3>⚙️ 属性面板</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedNode()" class="node-properties">
|
<div v-if="selectedNode" class="node-properties">
|
||||||
<div class="property-section">
|
<div class="property-section">
|
||||||
<h4>基本信息</h4>
|
<h4>基本信息</h4>
|
||||||
<div class="property-item">
|
<div class="property-item">
|
||||||
<label>节点名称:</label>
|
<label>节点名称:</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
:value="selectedNode().name"
|
:value="selectedNode.name"
|
||||||
@input="updateNodeProperty('name', $event.target.value)"
|
@input="updateNodeProperty('name', $event.target.value)"
|
||||||
|
:key="selectedNode.id + '_name'"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="property-item">
|
<div class="property-item">
|
||||||
<label>描述:</label>
|
<label>描述:</label>
|
||||||
<textarea
|
<textarea
|
||||||
:value="selectedNode().description"
|
:value="selectedNode.description"
|
||||||
@input="updateNodeProperty('description', $event.target.value)"
|
@input="updateNodeProperty('description', $event.target.value)"
|
||||||
|
:key="selectedNode.id + '_description'"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="property-section" v-if="selectedNode().properties">
|
<div class="property-section" v-if="selectedNode.properties">
|
||||||
<h4>节点属性</h4>
|
<h4>节点属性</h4>
|
||||||
<div
|
<div
|
||||||
v-for="(prop, key) in selectedNode().properties"
|
v-for="(prop, key) in selectedNode.properties"
|
||||||
:key="key"
|
:key="key"
|
||||||
class="property-item"
|
class="property-item"
|
||||||
>
|
>
|
||||||
@@ -309,18 +325,21 @@
|
|||||||
type="text"
|
type="text"
|
||||||
:value="prop.value"
|
:value="prop.value"
|
||||||
@input="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
|
@input="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
|
||||||
|
:key="selectedNode.id + '_' + key + '_string'"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="prop.type === 'number'"
|
v-else-if="prop.type === 'number'"
|
||||||
type="number"
|
type="number"
|
||||||
:value="prop.value"
|
:value="prop.value"
|
||||||
@input="updateNodeProperty('properties.' + key + '.value', parseFloat($event.target.value))"
|
@input="updateNodeProperty('properties.' + key + '.value', parseFloat($event.target.value) || 0)"
|
||||||
|
:key="selectedNode.id + '_' + key + '_number'"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="prop.type === 'boolean'"
|
v-else-if="prop.type === 'boolean'"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="prop.value"
|
:checked="prop.value"
|
||||||
@change="updateNodeProperty('properties.' + key + '.value', $event.target.checked)"
|
@change="updateNodeProperty('properties.' + key + '.value', $event.target.checked)"
|
||||||
|
:key="selectedNode.id + '_' + key + '_boolean'"
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
v-else-if="prop.type === 'code'"
|
v-else-if="prop.type === 'code'"
|
||||||
@@ -329,13 +348,20 @@
|
|||||||
rows="6"
|
rows="6"
|
||||||
class="code-input"
|
class="code-input"
|
||||||
placeholder="请输入代码..."
|
placeholder="请输入代码..."
|
||||||
|
:key="selectedNode.id + '_' + key + '_code'"
|
||||||
></textarea>
|
></textarea>
|
||||||
<select
|
<select
|
||||||
v-else-if="prop.type === 'select'"
|
v-else-if="prop.type === 'select'"
|
||||||
:value="prop.value"
|
:value="prop.value"
|
||||||
@change="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
|
@change="updateNodeProperty('properties.' + key + '.value', $event.target.value)"
|
||||||
|
:key="selectedNode.id + '_' + key + '_select_' + prop.value"
|
||||||
>
|
>
|
||||||
<option v-for="option in prop.options" :key="option" :value="option">
|
<option
|
||||||
|
v-for="option in prop.options"
|
||||||
|
:key="option"
|
||||||
|
:value="option"
|
||||||
|
:selected="option === prop.value"
|
||||||
|
>
|
||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -344,8 +370,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="property-section">
|
<div class="property-section">
|
||||||
<h4>代码预览</h4>
|
<h4>节点配置</h4>
|
||||||
<pre class="code-preview">{{ generateNodeCode(selectedNode()) }}</pre>
|
<pre class="config-preview">{{ selectedNode ? JSON.stringify(selectedNode, null, 2) : '{}' }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -380,16 +406,16 @@
|
|||||||
<div v-if="showExportModal" class="modal-overlay" @click="showExportModal = false">
|
<div v-if="showExportModal" class="modal-overlay" @click="showExportModal = false">
|
||||||
<div class="modal-content" @click.stop>
|
<div class="modal-content" @click.stop>
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>导出代码</h3>
|
<h3>导出配置</h3>
|
||||||
<button @click="showExportModal = false">×</button>
|
<button @click="showExportModal = false">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="export-options">
|
<div class="export-options">
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" v-model="exportFormat" value="typescript"> TypeScript
|
<input type="radio" v-model="exportFormat" value="json"> JSON配置
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" v-model="exportFormat" value="json"> JSON
|
<input type="radio" v-model="exportFormat" value="typescript"> TypeScript代码
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
Reference in New Issue
Block a user