* refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 * feat(editor): 添加插件市场功能 * feat(editor): 重构插件市场以支持版本管理和ZIP打包 * feat(editor): 重构插件发布流程并修复React渲染警告 * fix(plugin): 修复插件发布和市场的路径不一致问题 * feat: 重构插件发布流程并添加插件删除功能 * fix(editor): 完善插件删除功能并修复多个关键问题 * fix(auth): 修复自动登录与手动登录的竞态条件问题 * feat(editor): 重构插件管理流程 * feat(editor): 支持 ZIP 文件直接发布插件 - 新增 PluginSourceParser 解析插件源 - 重构发布流程支持文件夹和 ZIP 两种方式 - 优化发布向导 UI * feat(editor): 插件市场支持多版本安装 - 插件解压到项目 plugins 目录 - 新增 Tauri 后端安装/卸载命令 - 支持选择任意版本安装 - 修复打包逻辑,保留完整 dist 目录结构 * feat(editor): 个人中心支持多版本管理 - 合并同一插件的不同版本 - 添加版本历史展开/折叠功能 - 禁止有待审核 PR 时更新插件 * fix(editor): 修复 InspectorRegistry 服务注册 - InspectorRegistry 实现 IService 接口 - 注册到 Core.services 供插件使用 * feat(behavior-tree-editor): 完善插件注册和文件操作 - 添加文件创建模板和操作处理器 - 实现右键菜单创建行为树功能 - 修复文件读取权限问题(使用 Tauri 命令) - 添加 BehaviorTreeEditorPanel 组件 - 修复 rollup 配置支持动态导入 * feat(plugin): 完善插件构建和发布流程 * fix(behavior-tree-editor): 完整恢复编辑器并修复 Toast 集成 * fix(behavior-tree-editor): 修复节点选中、连线跟随和文件加载问题并优化性能 * fix(behavior-tree-editor): 修复端口连接失败问题并优化连线样式 * refactor(behavior-tree-editor): 移除调试面板功能简化代码结构 * refactor(behavior-tree-editor): 清理冗余代码合并重复逻辑 * feat(behavior-tree-editor): 完善编辑器核心功能增强扩展性 * fix(lint): 修复ESLint错误确保CI通过 * refactor(behavior-tree-editor): 优化编辑器工具栏和编译器功能 * refactor(behavior-tree-editor): 清理技术债务,优化代码质量 * fix(editor-app): 修复字符串替换安全问题
732 lines
28 KiB
TypeScript
732 lines
28 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||
import { NodeTemplate, BlackboardValueType } from '@esengine/behavior-tree';
|
||
import { useBehaviorTreeDataStore, BehaviorTreeNode, ROOT_NODE_ID } from '../stores';
|
||
import { useUIStore } from '../stores';
|
||
import { showToast as notificationShowToast } from '../services/NotificationService';
|
||
import { BlackboardValue } from '../domain/models/Blackboard';
|
||
import { GlobalBlackboardService } from '../application/services/GlobalBlackboardService';
|
||
import { BehaviorTreeCanvas } from './canvas/BehaviorTreeCanvas';
|
||
import { ConnectionLayer } from './connections/ConnectionLayer';
|
||
import { NodeFactory } from '../infrastructure/factories/NodeFactory';
|
||
import { TreeValidator } from '../domain/services/TreeValidator';
|
||
import { useNodeOperations } from '../hooks/useNodeOperations';
|
||
import { useConnectionOperations } from '../hooks/useConnectionOperations';
|
||
import { useCommandHistory } from '../hooks/useCommandHistory';
|
||
import { useNodeDrag } from '../hooks/useNodeDrag';
|
||
import { usePortConnection } from '../hooks/usePortConnection';
|
||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||
import { useDropHandler } from '../hooks/useDropHandler';
|
||
import { useCanvasMouseEvents } from '../hooks/useCanvasMouseEvents';
|
||
import { useContextMenu } from '../hooks/useContextMenu';
|
||
import { useQuickCreateMenu } from '../hooks/useQuickCreateMenu';
|
||
import { EditorToolbar } from './toolbar/EditorToolbar';
|
||
import { QuickCreateMenu } from './menu/QuickCreateMenu';
|
||
import { NodeContextMenu } from './menu/NodeContextMenu';
|
||
import { BehaviorTreeNode as BehaviorTreeNodeComponent } from './nodes/BehaviorTreeNode';
|
||
import { BlackboardPanel } from './blackboard/BlackboardPanel';
|
||
import { getPortPosition as getPortPositionUtil } from '../utils/portUtils';
|
||
import { useExecutionController } from '../hooks/useExecutionController';
|
||
import { useNodeTracking } from '../hooks/useNodeTracking';
|
||
import { useEditorHandlers } from '../hooks/useEditorHandlers';
|
||
import { ICON_MAP, DEFAULT_EDITOR_CONFIG } from '../config/editorConstants';
|
||
import '../styles/BehaviorTreeNode.css';
|
||
|
||
type BlackboardVariables = Record<string, BlackboardValue>;
|
||
|
||
interface BehaviorTreeEditorProps {
|
||
onNodeSelect?: (node: BehaviorTreeNode) => void;
|
||
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
|
||
blackboardVariables?: BlackboardVariables;
|
||
projectPath?: string | null;
|
||
showToolbar?: boolean;
|
||
showToast?: (message: string, type?: 'success' | 'error' | 'warning' | 'info') => void;
|
||
currentFileName?: string;
|
||
hasUnsavedChanges?: boolean;
|
||
onSave?: () => void;
|
||
onOpen?: () => void;
|
||
onExport?: () => void;
|
||
onCopyToClipboard?: () => void;
|
||
}
|
||
|
||
export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
||
onNodeSelect,
|
||
onNodeCreate,
|
||
blackboardVariables = {},
|
||
projectPath = null,
|
||
showToolbar = true,
|
||
showToast: showToastProp,
|
||
currentFileName,
|
||
hasUnsavedChanges = false,
|
||
onSave,
|
||
onOpen,
|
||
onExport,
|
||
onCopyToClipboard
|
||
}) => {
|
||
// 使用传入的 showToast 或回退到 NotificationService
|
||
const showToast = showToastProp || notificationShowToast;
|
||
|
||
// 数据 store(行为树数据 - 唯一数据源)
|
||
const {
|
||
canvasOffset,
|
||
canvasScale,
|
||
triggerForceUpdate,
|
||
sortChildrenByPosition,
|
||
setBlackboardVariables,
|
||
setInitialBlackboardVariables,
|
||
setIsExecuting,
|
||
initialBlackboardVariables,
|
||
isExecuting,
|
||
saveNodesDataSnapshot,
|
||
restoreNodesData,
|
||
nodeExecutionStatuses,
|
||
nodeExecutionOrders,
|
||
resetView
|
||
} = useBehaviorTreeDataStore();
|
||
|
||
// 使用缓存的节点和连接数组(store 中已经优化,只在 tree 真正变化时更新)
|
||
const nodes = useBehaviorTreeDataStore((state) => state.cachedNodes);
|
||
const connections = useBehaviorTreeDataStore((state) => state.cachedConnections);
|
||
|
||
// UI store(UI 交互状态)
|
||
const {
|
||
selectedNodeIds,
|
||
selectedConnection,
|
||
draggingNodeId,
|
||
dragStartPositions,
|
||
isDraggingNode,
|
||
dragDelta,
|
||
connectingFrom,
|
||
connectingFromProperty,
|
||
connectingToPos,
|
||
isBoxSelecting,
|
||
boxSelectStart,
|
||
boxSelectEnd,
|
||
setSelectedNodeIds,
|
||
setSelectedConnection,
|
||
startDragging,
|
||
stopDragging,
|
||
setIsDraggingNode,
|
||
setDragDelta,
|
||
setConnectingFrom,
|
||
setConnectingFromProperty,
|
||
setConnectingToPos,
|
||
clearConnecting,
|
||
setIsBoxSelecting,
|
||
setBoxSelectStart,
|
||
setBoxSelectEnd,
|
||
clearBoxSelect
|
||
} = useUIStore();
|
||
|
||
const canvasRef = useRef<HTMLDivElement>(null);
|
||
const stopExecutionRef = useRef<(() => void) | null>(null);
|
||
const justFinishedBoxSelectRef = useRef(false);
|
||
const [blackboardCollapsed, setBlackboardCollapsed] = useState(false);
|
||
const [globalVariables, setGlobalVariables] = useState<Record<string, BlackboardValue>>({});
|
||
|
||
const updateVariable = useBehaviorTreeDataStore((state) => state.updateBlackboardVariable);
|
||
|
||
const globalBlackboardService = useMemo(() => GlobalBlackboardService.getInstance(), []);
|
||
|
||
useEffect(() => {
|
||
if (projectPath) {
|
||
globalBlackboardService.setProjectPath(projectPath);
|
||
setGlobalVariables(globalBlackboardService.getVariablesMap());
|
||
}
|
||
|
||
const unsubscribe = globalBlackboardService.onChange(() => {
|
||
setGlobalVariables(globalBlackboardService.getVariablesMap());
|
||
});
|
||
|
||
return () => unsubscribe();
|
||
}, [globalBlackboardService, projectPath]);
|
||
|
||
const handleGlobalVariableAdd = useCallback((key: string, value: any, type: string) => {
|
||
try {
|
||
let bbType: BlackboardValueType;
|
||
switch (type) {
|
||
case 'number':
|
||
bbType = BlackboardValueType.Number;
|
||
break;
|
||
case 'boolean':
|
||
bbType = BlackboardValueType.Boolean;
|
||
break;
|
||
case 'object':
|
||
bbType = BlackboardValueType.Object;
|
||
break;
|
||
default:
|
||
bbType = BlackboardValueType.String;
|
||
}
|
||
|
||
globalBlackboardService.addVariable({ key, type: bbType, defaultValue: value });
|
||
showToast(`全局变量 "${key}" 已添加`, 'success');
|
||
} catch (error) {
|
||
showToast(`添加全局变量失败: ${error}`, 'error');
|
||
}
|
||
}, [globalBlackboardService, showToast]);
|
||
|
||
const handleGlobalVariableChange = useCallback((key: string, value: any) => {
|
||
try {
|
||
globalBlackboardService.updateVariable(key, { defaultValue: value });
|
||
} catch (error) {
|
||
showToast(`更新全局变量失败: ${error}`, 'error');
|
||
}
|
||
}, [globalBlackboardService, showToast]);
|
||
|
||
const handleGlobalVariableDelete = useCallback((key: string) => {
|
||
try {
|
||
globalBlackboardService.deleteVariable(key);
|
||
showToast(`全局变量 "${key}" 已删除`, 'success');
|
||
} catch (error) {
|
||
showToast(`删除全局变量失败: ${error}`, 'error');
|
||
}
|
||
}, [globalBlackboardService, showToast]);
|
||
|
||
// 监听框选状态变化,当框选结束时设置标记
|
||
useEffect(() => {
|
||
if (!isBoxSelecting && justFinishedBoxSelectRef.current) {
|
||
// 框选刚结束,在下一个事件循环清除标记
|
||
setTimeout(() => {
|
||
justFinishedBoxSelectRef.current = false;
|
||
}, 0);
|
||
} else if (isBoxSelecting) {
|
||
// 正在框选
|
||
justFinishedBoxSelectRef.current = true;
|
||
}
|
||
}, [isBoxSelecting]);
|
||
|
||
// Node factory
|
||
const nodeFactory = useMemo(() => new NodeFactory(), []);
|
||
|
||
// 验证器
|
||
const validator = useMemo(() => new TreeValidator(), []);
|
||
|
||
// 命令历史
|
||
const { commandManager, canUndo, canRedo, undo, redo } = useCommandHistory();
|
||
|
||
// 节点操作
|
||
const nodeOperations = useNodeOperations(
|
||
nodeFactory,
|
||
commandManager
|
||
);
|
||
|
||
// 连接操作
|
||
const connectionOperations = useConnectionOperations(
|
||
validator,
|
||
commandManager
|
||
);
|
||
|
||
// 上下文菜单
|
||
const contextMenu = useContextMenu();
|
||
|
||
// 执行控制器
|
||
const {
|
||
executionMode,
|
||
executionSpeed,
|
||
handlePlay,
|
||
handlePause,
|
||
handleStop,
|
||
handleStep,
|
||
handleSpeedChange,
|
||
controller
|
||
} = useExecutionController({
|
||
rootNodeId: ROOT_NODE_ID,
|
||
projectPath: projectPath || '',
|
||
blackboardVariables,
|
||
nodes,
|
||
connections,
|
||
initialBlackboardVariables,
|
||
onBlackboardUpdate: setBlackboardVariables,
|
||
onInitialBlackboardSave: setInitialBlackboardVariables,
|
||
onExecutingChange: setIsExecuting,
|
||
onSaveNodesDataSnapshot: saveNodesDataSnapshot,
|
||
onRestoreNodesData: restoreNodesData,
|
||
sortChildrenByPosition
|
||
});
|
||
|
||
const executorRef = useRef(null);
|
||
const { uncommittedNodeIds } = useNodeTracking({ nodes, executionMode });
|
||
|
||
// 快速创建菜单
|
||
const quickCreateMenu = useQuickCreateMenu({
|
||
nodeOperations,
|
||
connectionOperations,
|
||
canvasRef,
|
||
canvasOffset,
|
||
canvasScale,
|
||
connectingFrom,
|
||
connectingFromProperty,
|
||
clearConnecting,
|
||
nodes,
|
||
connections,
|
||
executionMode,
|
||
onStop: () => stopExecutionRef.current?.(),
|
||
onNodeCreate,
|
||
showToast
|
||
});
|
||
|
||
const {
|
||
handleNodeClick,
|
||
handleResetView,
|
||
handleClearCanvas
|
||
} = useEditorHandlers({
|
||
isDraggingNode,
|
||
selectedNodeIds,
|
||
setSelectedNodeIds,
|
||
resetView,
|
||
resetTree: useBehaviorTreeDataStore.getState().reset,
|
||
triggerForceUpdate,
|
||
onNodeSelect
|
||
});
|
||
|
||
// 添加缺少的处理函数
|
||
const handleCanvasClick = (e: React.MouseEvent) => {
|
||
// 如果正在框选或者刚刚结束框选,不要清空选择
|
||
// 因为 click 事件会在 mouseup 之后触发,会清空框选的结果
|
||
if (!isDraggingNode && !isBoxSelecting && !justFinishedBoxSelectRef.current) {
|
||
setSelectedNodeIds([]);
|
||
setSelectedConnection(null);
|
||
}
|
||
// 关闭右键菜单
|
||
contextMenu.closeContextMenu();
|
||
};
|
||
|
||
const handleCanvasContextMenu = (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
contextMenu.handleCanvasContextMenu(e);
|
||
};
|
||
|
||
const handleNodeContextMenu = (e: React.MouseEvent, node: BehaviorTreeNode) => {
|
||
e.preventDefault();
|
||
contextMenu.handleNodeContextMenu(e, node);
|
||
};
|
||
|
||
const handleConnectionClick = (e: React.MouseEvent, fromId: string, toId: string) => {
|
||
setSelectedConnection({ from: fromId, to: toId });
|
||
setSelectedNodeIds([]);
|
||
};
|
||
|
||
const handleCanvasDoubleClick = (e: React.MouseEvent) => {
|
||
quickCreateMenu.openQuickCreateMenu(
|
||
{ x: e.clientX, y: e.clientY },
|
||
'create'
|
||
);
|
||
};
|
||
|
||
// 黑板变量管理
|
||
const handleBlackboardVariableAdd = (key: string, value: any) => {
|
||
const newVariables = { ...blackboardVariables, [key]: value };
|
||
setBlackboardVariables(newVariables);
|
||
};
|
||
|
||
const handleBlackboardVariableChange = (key: string, value: any) => {
|
||
const newVariables = { ...blackboardVariables, [key]: value };
|
||
setBlackboardVariables(newVariables);
|
||
};
|
||
|
||
const handleBlackboardVariableDelete = (key: string) => {
|
||
const newVariables = { ...blackboardVariables };
|
||
delete newVariables[key];
|
||
setBlackboardVariables(newVariables);
|
||
};
|
||
|
||
const handleResetBlackboardVariable = (name: string) => {
|
||
const initialValue = initialBlackboardVariables[name];
|
||
if (initialValue !== undefined) {
|
||
updateVariable(name, initialValue);
|
||
}
|
||
};
|
||
|
||
const handleResetAllBlackboardVariables = () => {
|
||
setBlackboardVariables(initialBlackboardVariables);
|
||
};
|
||
|
||
const handleBlackboardVariableRename = (oldKey: string, newKey: string) => {
|
||
if (oldKey === newKey) return;
|
||
const newVariables = { ...blackboardVariables };
|
||
newVariables[newKey] = newVariables[oldKey];
|
||
delete newVariables[oldKey];
|
||
setBlackboardVariables(newVariables);
|
||
};
|
||
|
||
// 节点拖拽
|
||
const {
|
||
handleNodeMouseDown,
|
||
handleNodeMouseMove,
|
||
handleNodeMouseUp
|
||
} = useNodeDrag({
|
||
canvasRef,
|
||
canvasOffset,
|
||
canvasScale,
|
||
nodes,
|
||
selectedNodeIds,
|
||
draggingNodeId,
|
||
dragStartPositions,
|
||
isDraggingNode,
|
||
dragDelta,
|
||
nodeOperations,
|
||
setSelectedNodeIds,
|
||
startDragging,
|
||
stopDragging,
|
||
setIsDraggingNode,
|
||
setDragDelta,
|
||
setIsBoxSelecting,
|
||
setBoxSelectStart,
|
||
setBoxSelectEnd,
|
||
sortChildrenByPosition
|
||
});
|
||
|
||
// 端口连接
|
||
const {
|
||
handlePortMouseDown,
|
||
handlePortMouseUp,
|
||
handleNodeMouseUpForConnection
|
||
} = usePortConnection({
|
||
canvasRef,
|
||
canvasOffset,
|
||
canvasScale,
|
||
nodes,
|
||
connections,
|
||
connectingFrom,
|
||
connectingFromProperty,
|
||
connectionOperations,
|
||
setConnectingFrom,
|
||
setConnectingFromProperty,
|
||
clearConnecting,
|
||
sortChildrenByPosition,
|
||
showToast
|
||
});
|
||
|
||
// 键盘快捷键
|
||
useKeyboardShortcuts({
|
||
selectedNodeIds,
|
||
selectedConnection,
|
||
connections,
|
||
nodeOperations,
|
||
connectionOperations,
|
||
setSelectedNodeIds,
|
||
setSelectedConnection
|
||
});
|
||
|
||
// 拖放处理
|
||
const {
|
||
isDragging,
|
||
handleDrop,
|
||
handleDragOver,
|
||
handleDragLeave,
|
||
handleDragEnter
|
||
} = useDropHandler({
|
||
canvasRef,
|
||
canvasOffset,
|
||
canvasScale,
|
||
nodeOperations,
|
||
onNodeCreate
|
||
});
|
||
|
||
// 画布鼠标事件
|
||
const {
|
||
handleCanvasMouseMove,
|
||
handleCanvasMouseUp,
|
||
handleCanvasMouseDown
|
||
} = useCanvasMouseEvents({
|
||
canvasRef,
|
||
canvasOffset,
|
||
canvasScale,
|
||
connectingFrom,
|
||
connectingFromProperty,
|
||
connectingToPos,
|
||
isBoxSelecting,
|
||
boxSelectStart,
|
||
boxSelectEnd,
|
||
nodes,
|
||
selectedNodeIds,
|
||
quickCreateMenu: quickCreateMenu.quickCreateMenu,
|
||
setConnectingToPos,
|
||
setIsBoxSelecting,
|
||
setBoxSelectStart,
|
||
setBoxSelectEnd,
|
||
setSelectedNodeIds,
|
||
setSelectedConnection,
|
||
setQuickCreateMenu: quickCreateMenu.setQuickCreateMenu,
|
||
clearConnecting,
|
||
clearBoxSelect,
|
||
showToast
|
||
});
|
||
|
||
const handleCombinedMouseMove = (e: React.MouseEvent) => {
|
||
handleCanvasMouseMove(e);
|
||
handleNodeMouseMove(e);
|
||
};
|
||
|
||
const handleCombinedMouseUp = (e: React.MouseEvent) => {
|
||
handleCanvasMouseUp(e);
|
||
handleNodeMouseUp();
|
||
};
|
||
|
||
const getPortPosition = (nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output') =>
|
||
getPortPositionUtil(canvasRef, canvasOffset, canvasScale, nodes, nodeId, propertyName, portType, draggingNodeId, dragDelta, selectedNodeIds);
|
||
|
||
stopExecutionRef.current = handleStop;
|
||
|
||
return (
|
||
<div style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
flex: 1,
|
||
backgroundColor: '#1e1e1e',
|
||
display: 'flex',
|
||
flexDirection: 'column'
|
||
}}>
|
||
{showToolbar && (
|
||
<EditorToolbar
|
||
executionMode={executionMode}
|
||
canUndo={canUndo}
|
||
canRedo={canRedo}
|
||
hasUnsavedChanges={hasUnsavedChanges}
|
||
onPlay={handlePlay}
|
||
onPause={handlePause}
|
||
onStop={handleStop}
|
||
onStep={handleStep}
|
||
onReset={handleStop}
|
||
onUndo={undo}
|
||
onRedo={redo}
|
||
onResetView={handleResetView}
|
||
onSave={onSave}
|
||
onOpen={onOpen}
|
||
onExport={onExport}
|
||
onCopyToClipboard={onCopyToClipboard}
|
||
/>
|
||
)}
|
||
|
||
{/* 主内容区:画布 + 黑板面板 */}
|
||
<div style={{
|
||
flex: 1,
|
||
display: 'flex',
|
||
overflow: 'hidden',
|
||
position: 'relative'
|
||
}}>
|
||
{/* 画布区域 */}
|
||
<div style={{
|
||
flex: 1,
|
||
position: 'relative',
|
||
overflow: 'hidden'
|
||
}}>
|
||
|
||
<BehaviorTreeCanvas
|
||
ref={canvasRef}
|
||
config={DEFAULT_EDITOR_CONFIG}
|
||
onClick={handleCanvasClick}
|
||
onContextMenu={handleCanvasContextMenu}
|
||
onDoubleClick={handleCanvasDoubleClick}
|
||
onMouseMove={handleCombinedMouseMove}
|
||
onMouseDown={handleCanvasMouseDown}
|
||
onMouseUp={handleCombinedMouseUp}
|
||
onDrop={handleDrop}
|
||
onDragOver={handleDragOver}
|
||
onDragEnter={handleDragEnter}
|
||
onDragLeave={handleDragLeave}
|
||
>
|
||
{/* 连接线层 */}
|
||
<ConnectionLayer
|
||
connections={connections}
|
||
nodes={nodes}
|
||
selectedConnection={selectedConnection}
|
||
getPortPosition={getPortPosition}
|
||
onConnectionClick={(e, fromId, toId) => {
|
||
setSelectedConnection({ from: fromId, to: toId });
|
||
setSelectedNodeIds([]);
|
||
}}
|
||
/>
|
||
|
||
{/* 正在拖拽的连接线预览 */}
|
||
{connectingFrom && connectingToPos && (
|
||
<svg style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
width: '100%',
|
||
height: '100%',
|
||
pointerEvents: 'none',
|
||
overflow: 'visible',
|
||
zIndex: 150
|
||
}}>
|
||
{(() => {
|
||
// 获取正在连接的端口类型
|
||
const fromPortType = canvasRef.current?.getAttribute('data-connecting-from-port-type') || '';
|
||
|
||
// 根据端口类型判断是从输入还是输出端口开始
|
||
let portType: 'input' | 'output' = 'output';
|
||
if (fromPortType === 'node-input' || fromPortType === 'property-input') {
|
||
portType = 'input';
|
||
}
|
||
|
||
const fromPos = getPortPosition(
|
||
connectingFrom,
|
||
connectingFromProperty || undefined,
|
||
portType
|
||
);
|
||
if (!fromPos) return null;
|
||
|
||
const isPropertyConnection = !!connectingFromProperty;
|
||
const x1 = fromPos.x;
|
||
const y1 = fromPos.y;
|
||
const x2 = connectingToPos.x;
|
||
const y2 = connectingToPos.y;
|
||
|
||
// 使用贝塞尔曲线渲染
|
||
let pathD: string;
|
||
if (isPropertyConnection) {
|
||
// 属性连接使用水平贝塞尔曲线
|
||
const controlX1 = x1 + (x2 - x1) * 0.5;
|
||
const controlX2 = x1 + (x2 - x1) * 0.5;
|
||
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
|
||
} else {
|
||
// 节点连接使用垂直贝塞尔曲线
|
||
const controlY = y1 + (y2 - y1) * 0.5;
|
||
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
|
||
}
|
||
|
||
return (
|
||
<path
|
||
d={pathD}
|
||
stroke={isPropertyConnection ? '#ab47bc' : '#00bcd4'}
|
||
strokeWidth="2.5"
|
||
fill="none"
|
||
strokeDasharray={isPropertyConnection ? '5,5' : 'none'}
|
||
strokeLinecap="round"
|
||
/>
|
||
);
|
||
})()}
|
||
</svg>
|
||
)}
|
||
|
||
{/* 节点层 */}
|
||
{nodes.map((node: BehaviorTreeNode) => (
|
||
<BehaviorTreeNodeComponent
|
||
key={node.id}
|
||
node={node}
|
||
isSelected={selectedNodeIds.includes(node.id)}
|
||
isBeingDragged={draggingNodeId === node.id}
|
||
dragDelta={dragDelta}
|
||
uncommittedNodeIds={uncommittedNodeIds}
|
||
blackboardVariables={blackboardVariables}
|
||
initialBlackboardVariables={initialBlackboardVariables}
|
||
isExecuting={isExecuting}
|
||
executionStatus={nodeExecutionStatuses.get(node.id)}
|
||
executionOrder={nodeExecutionOrders.get(node.id)}
|
||
connections={connections}
|
||
nodes={nodes}
|
||
executorRef={executorRef}
|
||
iconMap={ICON_MAP}
|
||
draggingNodeId={draggingNodeId}
|
||
onNodeClick={handleNodeClick}
|
||
onContextMenu={handleNodeContextMenu}
|
||
onNodeMouseDown={handleNodeMouseDown}
|
||
onNodeMouseUpForConnection={handleNodeMouseUpForConnection}
|
||
onPortMouseDown={handlePortMouseDown}
|
||
onPortMouseUp={handlePortMouseUp}
|
||
/>
|
||
))}
|
||
</BehaviorTreeCanvas>
|
||
|
||
{/* 框选区域 - 在画布外层,这样才能显示在节点上方 */}
|
||
{isBoxSelecting && boxSelectStart && boxSelectEnd && canvasRef.current && (() => {
|
||
const rect = canvasRef.current.getBoundingClientRect();
|
||
const minX = Math.min(boxSelectStart.x, boxSelectEnd.x);
|
||
const minY = Math.min(boxSelectStart.y, boxSelectEnd.y);
|
||
const maxX = Math.max(boxSelectStart.x, boxSelectEnd.x);
|
||
const maxY = Math.max(boxSelectStart.y, boxSelectEnd.y);
|
||
|
||
return (
|
||
<div style={{
|
||
position: 'fixed',
|
||
left: rect.left + minX * canvasScale + canvasOffset.x,
|
||
top: rect.top + minY * canvasScale + canvasOffset.y,
|
||
width: (maxX - minX) * canvasScale,
|
||
height: (maxY - minY) * canvasScale,
|
||
border: '1px dashed #4a90e2',
|
||
backgroundColor: 'rgba(74, 144, 226, 0.1)',
|
||
pointerEvents: 'none',
|
||
zIndex: 9999
|
||
}} />
|
||
);
|
||
})()}
|
||
|
||
{/* 右键菜单 */}
|
||
<NodeContextMenu
|
||
visible={contextMenu.contextMenu.visible}
|
||
position={contextMenu.contextMenu.position}
|
||
nodeId={contextMenu.contextMenu.nodeId}
|
||
isBlackboardVariable={contextMenu.contextMenu.nodeId ? nodes.find((n) => n.id === contextMenu.contextMenu.nodeId)?.data.nodeType === 'blackboard-variable' : false}
|
||
onReplaceNode={() => {
|
||
if (contextMenu.contextMenu.nodeId) {
|
||
quickCreateMenu.openQuickCreateMenu(
|
||
contextMenu.contextMenu.position,
|
||
'replace',
|
||
contextMenu.contextMenu.nodeId
|
||
);
|
||
}
|
||
contextMenu.closeContextMenu();
|
||
}}
|
||
onDeleteNode={() => {
|
||
if (contextMenu.contextMenu.nodeId) {
|
||
nodeOperations.deleteNode(contextMenu.contextMenu.nodeId);
|
||
}
|
||
contextMenu.closeContextMenu();
|
||
}}
|
||
onCreateNode={() => {
|
||
quickCreateMenu.openQuickCreateMenu(
|
||
contextMenu.contextMenu.position,
|
||
'create'
|
||
);
|
||
contextMenu.closeContextMenu();
|
||
}}
|
||
/>
|
||
|
||
{/* 快速创建菜单 */}
|
||
<QuickCreateMenu
|
||
visible={quickCreateMenu.quickCreateMenu.visible}
|
||
position={quickCreateMenu.quickCreateMenu.position}
|
||
searchText={quickCreateMenu.quickCreateMenu.searchText}
|
||
selectedIndex={quickCreateMenu.quickCreateMenu.selectedIndex}
|
||
mode={quickCreateMenu.quickCreateMenu.mode}
|
||
iconMap={ICON_MAP}
|
||
onSearchChange={(text) => quickCreateMenu.setQuickCreateMenu((prev) => ({ ...prev, searchText: text }))}
|
||
onIndexChange={(index) => quickCreateMenu.setQuickCreateMenu((prev) => ({ ...prev, selectedIndex: index }))}
|
||
onNodeSelect={(template) => {
|
||
if (quickCreateMenu.quickCreateMenu.mode === 'create') {
|
||
quickCreateMenu.handleQuickCreateNode(template);
|
||
} else {
|
||
quickCreateMenu.handleReplaceNode(template);
|
||
}
|
||
}}
|
||
onClose={() => quickCreateMenu.setQuickCreateMenu((prev) => ({ ...prev, visible: false }))}
|
||
/>
|
||
|
||
</div>
|
||
|
||
{/* 黑板面板(侧边栏) */}
|
||
<div style={{
|
||
width: blackboardCollapsed ? '48px' : '300px',
|
||
flexShrink: 0,
|
||
transition: 'width 0.2s ease'
|
||
}}>
|
||
<BlackboardPanel
|
||
variables={blackboardVariables}
|
||
initialVariables={initialBlackboardVariables}
|
||
globalVariables={globalVariables}
|
||
onVariableAdd={handleBlackboardVariableAdd}
|
||
onVariableChange={handleBlackboardVariableChange}
|
||
onVariableDelete={handleBlackboardVariableDelete}
|
||
onVariableRename={handleBlackboardVariableRename}
|
||
onGlobalVariableChange={handleGlobalVariableChange}
|
||
onGlobalVariableAdd={handleGlobalVariableAdd}
|
||
onGlobalVariableDelete={handleGlobalVariableDelete}
|
||
isCollapsed={blackboardCollapsed}
|
||
onToggleCollapse={() => setBlackboardCollapsed(!blackboardCollapsed)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|