Feature/runtime cdn and plugin loader (#240)
* feat(ui): 完善 UI 布局系统和编辑器可视化工具 * refactor: 移除 ModuleRegistry,统一使用 PluginManager 插件系统 * fix: 修复 CodeQL 警告并提升测试覆盖率 * refactor: 分离运行时入口点,解决 runtime bundle 包含 React 的问题 * fix(ci): 添加 editor-core 和 editor-runtime 到 CI 依赖构建步骤 * docs: 完善 ServiceContainer 文档,新增 Symbol.for 模式和 @InjectProperty 说明 * fix(ci): 修复 type-check 失败问题 * fix(ci): 修复类型检查失败问题 * fix(ci): 修复类型检查失败问题 * fix(ci): behavior-tree 构建添加 @tauri-apps 外部依赖 * fix(ci): behavior-tree 添加 @tauri-apps/plugin-fs 类型依赖 * fix(ci): platform-web 添加缺失的 behavior-tree 依赖 * fix(lint): 移除正则表达式中不必要的转义字符
This commit is contained in:
@@ -1,731 +0,0 @@
|
||||
import { React, useEffect, useMemo, useRef, useState, useCallback } from '@esengine/editor-runtime';
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,791 +0,0 @@
|
||||
import { React, useState, Icons } from '@esengine/editor-runtime';
|
||||
|
||||
const { Clipboard, Edit2, Trash2, ChevronDown, ChevronRight, Globe, GripVertical, ChevronLeft, Plus, Copy } = Icons;
|
||||
|
||||
type SimpleBlackboardType = 'number' | 'string' | 'boolean' | 'object';
|
||||
|
||||
interface BlackboardPanelProps {
|
||||
variables: Record<string, any>;
|
||||
initialVariables?: Record<string, any>;
|
||||
globalVariables?: Record<string, any>;
|
||||
onVariableChange: (key: string, value: any) => void;
|
||||
onVariableAdd: (key: string, value: any, type: SimpleBlackboardType) => void;
|
||||
onVariableDelete: (key: string) => void;
|
||||
onVariableRename?: (oldKey: string, newKey: string) => void;
|
||||
onGlobalVariableChange?: (key: string, value: any) => void;
|
||||
onGlobalVariableAdd?: (key: string, value: any, type: SimpleBlackboardType) => void;
|
||||
onGlobalVariableDelete?: (key: string) => void;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 黑板面板组件 - 内嵌在编辑器侧边
|
||||
* 支持本地变量和全局变量的管理
|
||||
*/
|
||||
export const BlackboardPanel: React.FC<BlackboardPanelProps> = ({
|
||||
variables,
|
||||
initialVariables,
|
||||
globalVariables,
|
||||
onVariableChange,
|
||||
onVariableAdd,
|
||||
onVariableDelete,
|
||||
onVariableRename,
|
||||
onGlobalVariableChange,
|
||||
onGlobalVariableAdd,
|
||||
onGlobalVariableDelete,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'local' | 'global'>('local');
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
const [newType, setNewType] = useState<SimpleBlackboardType>('string');
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editingNewKey, setEditingNewKey] = useState('');
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [editType, setEditType] = useState<SimpleBlackboardType>('string');
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const isModified = (key: string): boolean => {
|
||||
if (!initialVariables || viewMode !== 'local') return false;
|
||||
return JSON.stringify(variables[key]) !== JSON.stringify(initialVariables[key]);
|
||||
};
|
||||
|
||||
const handleAddVariable = () => {
|
||||
if (!newKey.trim()) return;
|
||||
|
||||
let parsedValue: any = newValue;
|
||||
if (newType === 'number') {
|
||||
parsedValue = parseFloat(newValue) || 0;
|
||||
} else if (newType === 'boolean') {
|
||||
parsedValue = newValue === 'true';
|
||||
} else if (newType === 'object') {
|
||||
try {
|
||||
parsedValue = JSON.parse(newValue);
|
||||
} catch {
|
||||
parsedValue = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (viewMode === 'global' && onGlobalVariableAdd) {
|
||||
onGlobalVariableAdd(newKey, parsedValue, newType);
|
||||
} else {
|
||||
onVariableAdd(newKey, parsedValue, newType);
|
||||
}
|
||||
|
||||
setNewKey('');
|
||||
setNewValue('');
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleStartEdit = (key: string, value: any) => {
|
||||
setEditingKey(key);
|
||||
setEditingNewKey(key);
|
||||
const currentType = getVariableType(value);
|
||||
setEditType(currentType);
|
||||
setEditValue(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
|
||||
};
|
||||
|
||||
const handleSaveEdit = (key: string) => {
|
||||
const newKey = editingNewKey.trim();
|
||||
if (!newKey) return;
|
||||
|
||||
let parsedValue: any = editValue;
|
||||
if (editType === 'number') {
|
||||
parsedValue = parseFloat(editValue) || 0;
|
||||
} else if (editType === 'boolean') {
|
||||
parsedValue = editValue === 'true' || editValue === '1';
|
||||
} else if (editType === 'object') {
|
||||
try {
|
||||
parsedValue = JSON.parse(editValue);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (viewMode === 'global' && onGlobalVariableChange) {
|
||||
if (newKey !== key && onGlobalVariableDelete) {
|
||||
onGlobalVariableDelete(key);
|
||||
}
|
||||
onGlobalVariableChange(newKey, parsedValue);
|
||||
} else {
|
||||
if (newKey !== key && onVariableRename) {
|
||||
onVariableRename(key, newKey);
|
||||
}
|
||||
onVariableChange(newKey, parsedValue);
|
||||
}
|
||||
|
||||
setEditingKey(null);
|
||||
};
|
||||
|
||||
const toggleGroup = (groupName: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(groupName)) {
|
||||
newSet.delete(groupName);
|
||||
} else {
|
||||
newSet.add(groupName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const getVariableType = (value: any): SimpleBlackboardType => {
|
||||
if (typeof value === 'number') return 'number';
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (typeof value === 'object') return 'object';
|
||||
return 'string';
|
||||
};
|
||||
|
||||
const currentVariables = viewMode === 'global' ? (globalVariables || {}) : variables;
|
||||
const variableEntries = Object.entries(currentVariables);
|
||||
const currentOnDelete = viewMode === 'global' ? onGlobalVariableDelete : onVariableDelete;
|
||||
|
||||
const groupedVariables: Record<string, Array<{ fullKey: string; varName: string; value: any }>> = variableEntries.reduce((groups, [key, value]) => {
|
||||
const parts = key.split('.');
|
||||
const groupName = (parts.length > 1 && parts[0]) ? parts[0] : 'default';
|
||||
const varName = parts.length > 1 ? parts.slice(1).join('.') : key;
|
||||
|
||||
if (!groups[groupName]) {
|
||||
groups[groupName] = [];
|
||||
}
|
||||
groups[groupName].push({ fullKey: key, varName, value });
|
||||
return groups;
|
||||
}, {} as Record<string, Array<{ fullKey: string; varName: string; value: any }>>);
|
||||
|
||||
const groupNames = Object.keys(groupedVariables).sort((a, b) => {
|
||||
if (a === 'default') return 1;
|
||||
if (b === 'default') return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// 复制变量到剪贴板
|
||||
const handleCopyVariable = (key: string, value: any) => {
|
||||
const text = `${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`;
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#1e1e1e',
|
||||
color: '#cccccc',
|
||||
borderLeft: '1px solid #333',
|
||||
transition: 'width 0.2s ease'
|
||||
}}>
|
||||
{/* 标题栏 */}
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
backgroundColor: '#252525',
|
||||
borderBottom: '1px solid #333',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
color: '#ccc'
|
||||
}}>
|
||||
<Clipboard size={14} />
|
||||
{!isCollapsed && <span>Blackboard</span>}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
{!isCollapsed && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
backgroundColor: '#1e1e1e',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setViewMode('local')}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: viewMode === 'local' ? '#007acc' : 'transparent',
|
||||
border: 'none',
|
||||
color: viewMode === 'local' ? '#fff' : '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (viewMode !== 'local') {
|
||||
e.currentTarget.style.backgroundColor = '#2a2a2a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (viewMode !== 'local') {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Clipboard size={11} />
|
||||
Local
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('global')}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
backgroundColor: viewMode === 'global' ? '#007acc' : 'transparent',
|
||||
border: 'none',
|
||||
color: viewMode === 'global' ? '#fff' : '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3px',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (viewMode !== 'global') {
|
||||
e.currentTarget.style.backgroundColor = '#2a2a2a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (viewMode !== 'global') {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Globe size={11} />
|
||||
Global
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{onToggleCollapse && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
style={{
|
||||
padding: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '2px',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#3c3c3c';
|
||||
e.currentTarget.style.color = '#ccc';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#888';
|
||||
}}
|
||||
title={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
<ChevronLeft size={14} style={{
|
||||
transform: isCollapsed ? 'rotate(180deg)' : 'none',
|
||||
transition: 'transform 0.2s'
|
||||
}} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{/* 工具栏 */}
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#222',
|
||||
borderBottom: '1px solid #333',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
fontSize: '10px',
|
||||
color: '#888'
|
||||
}}>
|
||||
{viewMode === 'local' ? '当前行为树的本地变量' : '所有行为树共享的全局变量'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#007acc',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
transition: 'background-color 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#005a9e'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#007acc'}
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 变量列表 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '10px'
|
||||
}}>
|
||||
{variableEntries.length === 0 && !isAdding && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
padding: '20px'
|
||||
}}>
|
||||
No variables yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加新变量表单 */}
|
||||
{isAdding && (
|
||||
<div style={{
|
||||
marginBottom: '10px',
|
||||
padding: '10px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #3c3c3c'
|
||||
}}>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Name</div>
|
||||
<input
|
||||
type="text"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
placeholder="variable.name"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '3px',
|
||||
color: '#9cdcfe',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Type</div>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value as SimpleBlackboardType)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<option value="string">String</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="object">Object (JSON)</option>
|
||||
</select>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Value</div>
|
||||
<textarea
|
||||
placeholder={
|
||||
newType === 'object' ? '{"key": "value"}' :
|
||||
newType === 'boolean' ? 'true or false' :
|
||||
newType === 'number' ? '0' : 'value'
|
||||
}
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: newType === 'object' ? '80px' : '30px',
|
||||
padding: '6px',
|
||||
marginBottom: '8px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '3px',
|
||||
color: '#cccccc',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '5px' }}>
|
||||
<button
|
||||
onClick={handleAddVariable}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAdding(false);
|
||||
setNewKey('');
|
||||
setNewValue('');
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '3px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分组显示变量 */}
|
||||
{groupNames.map((groupName) => {
|
||||
const isGroupCollapsed = collapsedGroups.has(groupName);
|
||||
const groupVars = groupedVariables[groupName];
|
||||
if (!groupVars) return null;
|
||||
|
||||
return (
|
||||
<div key={groupName} style={{ marginBottom: '8px' }}>
|
||||
{groupName !== 'default' && (
|
||||
<div
|
||||
onClick={() => toggleGroup(groupName)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '4px 6px',
|
||||
backgroundColor: '#252525',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
marginBottom: '4px',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
{isGroupCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
color: '#888'
|
||||
}}>
|
||||
{groupName} ({groupVars.length})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isGroupCollapsed && groupVars.map(({ fullKey: key, varName, value }) => {
|
||||
const type = getVariableType(value);
|
||||
const isEditing = editingKey === key;
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
const variableData = {
|
||||
variableName: key,
|
||||
variableValue: value,
|
||||
variableType: type
|
||||
};
|
||||
e.dataTransfer.setData('application/blackboard-variable', JSON.stringify(variableData));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
const typeColor =
|
||||
type === 'number' ? '#4ec9b0' :
|
||||
type === 'boolean' ? '#569cd6' :
|
||||
type === 'object' ? '#ce9178' : '#d4d4d4';
|
||||
|
||||
const displayValue = type === 'object' ?
|
||||
JSON.stringify(value) :
|
||||
String(value);
|
||||
|
||||
const truncatedValue = displayValue.length > 30 ?
|
||||
displayValue.substring(0, 30) + '...' :
|
||||
displayValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
draggable={!isEditing}
|
||||
onDragStart={handleDragStart}
|
||||
style={{
|
||||
marginBottom: '6px',
|
||||
padding: '6px 8px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
borderRadius: '3px',
|
||||
borderLeft: `3px solid ${typeColor}`,
|
||||
cursor: isEditing ? 'default' : 'grab'
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Name</div>
|
||||
<input
|
||||
type="text"
|
||||
value={editingNewKey}
|
||||
onChange={(e) => setEditingNewKey(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '2px',
|
||||
color: '#9cdcfe',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Type</div>
|
||||
<select
|
||||
value={editType}
|
||||
onChange={(e) => setEditType(e.target.value as SimpleBlackboardType)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3c3c3c',
|
||||
borderRadius: '2px',
|
||||
color: '#cccccc',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
<option value="string">String</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="boolean">Boolean</option>
|
||||
<option value="object">Object (JSON)</option>
|
||||
</select>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Value</div>
|
||||
<textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: editType === 'object' ? '60px' : '24px',
|
||||
padding: '4px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #0e639c',
|
||||
borderRadius: '2px',
|
||||
color: '#cccccc',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'monospace',
|
||||
resize: 'vertical',
|
||||
marginBottom: '4px'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => handleSaveEdit(key)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingKey(null)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '3px 8px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '2px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#9cdcfe',
|
||||
fontWeight: 'bold',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}>
|
||||
<GripVertical size={10} style={{ opacity: 0.3, flexShrink: 0 }} />
|
||||
{varName}
|
||||
<span style={{
|
||||
color: '#666',
|
||||
fontWeight: 'normal',
|
||||
fontSize: '10px'
|
||||
}}>({type})</span>
|
||||
{isModified(key) && (
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
color: '#ff9800',
|
||||
fontWeight: 'bold'
|
||||
}}>*</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#888',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{truncatedValue}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '2px', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => handleCopyVariable(key, value)}
|
||||
style={{
|
||||
padding: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#3c3c3c';
|
||||
e.currentTarget.style.color = '#ccc';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#888';
|
||||
}}
|
||||
title="Copy"
|
||||
>
|
||||
<Copy size={11} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStartEdit(key, value)}
|
||||
style={{
|
||||
padding: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#3c3c3c';
|
||||
e.currentTarget.style.color = '#ccc';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#888';
|
||||
}}
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 size={11} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => currentOnDelete && currentOnDelete(key)}
|
||||
style={{
|
||||
padding: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#5a1a1a';
|
||||
e.currentTarget.style.color = '#f48771';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#888';
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 底部信息栏 */}
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
borderTop: '1px solid #333',
|
||||
fontSize: '11px',
|
||||
color: '#666',
|
||||
backgroundColor: '#252525'
|
||||
}}>
|
||||
{viewMode === 'local' ? 'Local' : 'Global'}: {variableEntries.length} variable{variableEntries.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 滚动条样式 */}
|
||||
<style>{`
|
||||
.blackboard-scrollable::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.blackboard-scrollable::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
.blackboard-scrollable::-webkit-scrollbar-thumb {
|
||||
background: #3c3c3c;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.blackboard-scrollable::-webkit-scrollbar-thumb:hover {
|
||||
background: #4c4c4c;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,216 +0,0 @@
|
||||
import { React, useRef, useCallback, forwardRef, useState, useEffect } from '@esengine/editor-runtime';
|
||||
import { useCanvasInteraction } from '../../hooks/useCanvasInteraction';
|
||||
import { EditorConfig } from '../../types';
|
||||
import { GridBackground } from './GridBackground';
|
||||
|
||||
/**
|
||||
* 画布组件属性
|
||||
*/
|
||||
interface BehaviorTreeCanvasProps {
|
||||
/**
|
||||
* 编辑器配置
|
||||
*/
|
||||
config: EditorConfig;
|
||||
|
||||
/**
|
||||
* 子组件
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* 画布点击事件
|
||||
*/
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 画布双击事件
|
||||
*/
|
||||
onDoubleClick?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 画布右键事件
|
||||
*/
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 鼠标移动事件
|
||||
*/
|
||||
onMouseMove?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 鼠标按下事件
|
||||
*/
|
||||
onMouseDown?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 鼠标抬起事件
|
||||
*/
|
||||
onMouseUp?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 鼠标离开事件
|
||||
*/
|
||||
onMouseLeave?: (e: React.MouseEvent) => void;
|
||||
|
||||
/**
|
||||
* 拖放事件
|
||||
*/
|
||||
onDrop?: (e: React.DragEvent) => void;
|
||||
|
||||
/**
|
||||
* 拖动悬停事件
|
||||
*/
|
||||
onDragOver?: (e: React.DragEvent) => void;
|
||||
|
||||
/**
|
||||
* 拖动进入事件
|
||||
*/
|
||||
onDragEnter?: (e: React.DragEvent) => void;
|
||||
|
||||
/**
|
||||
* 拖动离开事件
|
||||
*/
|
||||
onDragLeave?: (e: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树画布组件
|
||||
* 负责画布的渲染、缩放、平移等基础功能
|
||||
*/
|
||||
export const BehaviorTreeCanvas = forwardRef<HTMLDivElement, BehaviorTreeCanvasProps>(({
|
||||
config,
|
||||
children,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
onMouseMove,
|
||||
onMouseDown,
|
||||
onMouseUp,
|
||||
onMouseLeave,
|
||||
onDrop,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave
|
||||
}, forwardedRef) => {
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = (forwardedRef as React.RefObject<HTMLDivElement>) || internalRef;
|
||||
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
const {
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
isPanning,
|
||||
handleWheel,
|
||||
startPanning,
|
||||
updatePanning,
|
||||
stopPanning
|
||||
} = useCanvasInteraction();
|
||||
|
||||
// 监听画布尺寸变化
|
||||
useEffect(() => {
|
||||
const updateSize = () => {
|
||||
if (canvasRef.current) {
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
setCanvasSize({
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateSize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateSize);
|
||||
if (canvasRef.current) {
|
||||
resizeObserver.observe(canvasRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [canvasRef]);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.altKey)) {
|
||||
e.preventDefault();
|
||||
startPanning(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
onMouseDown?.(e);
|
||||
}, [startPanning, onMouseDown]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (isPanning) {
|
||||
updatePanning(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
onMouseMove?.(e);
|
||||
}, [isPanning, updatePanning, onMouseMove]);
|
||||
|
||||
const handleMouseUp = useCallback((e: React.MouseEvent) => {
|
||||
if (isPanning) {
|
||||
stopPanning();
|
||||
}
|
||||
|
||||
onMouseUp?.(e);
|
||||
}, [isPanning, stopPanning, onMouseUp]);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onContextMenu?.(e);
|
||||
}, [onContextMenu]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="behavior-tree-canvas"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
cursor: isPanning ? 'grabbing' : 'default',
|
||||
backgroundColor: '#1a1a1a'
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
>
|
||||
{/* 网格背景 */}
|
||||
{config.showGrid && canvasSize.width > 0 && canvasSize.height > 0 && (
|
||||
<GridBackground
|
||||
canvasOffset={canvasOffset}
|
||||
canvasScale={canvasScale}
|
||||
width={canvasSize.width}
|
||||
height={canvasSize.height}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 内容容器(应用变换) */}
|
||||
<div
|
||||
className="canvas-content"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transformOrigin: '0 0',
|
||||
transform: `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasScale})`,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
BehaviorTreeCanvas.displayName = 'BehaviorTreeCanvas';
|
||||
@@ -1,127 +0,0 @@
|
||||
import { React, useMemo } from '@esengine/editor-runtime';
|
||||
|
||||
interface GridBackgroundProps {
|
||||
canvasOffset: { x: number; y: number };
|
||||
canvasScale: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器网格背景
|
||||
*/
|
||||
export const GridBackground: React.FC<GridBackgroundProps> = ({
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
width,
|
||||
height
|
||||
}) => {
|
||||
const gridPattern = useMemo(() => {
|
||||
// 基础网格大小(未缩放)
|
||||
const baseGridSize = 20;
|
||||
const baseDotSize = 1.5;
|
||||
|
||||
// 根据缩放级别调整网格大小
|
||||
const gridSize = baseGridSize * canvasScale;
|
||||
const dotSize = Math.max(baseDotSize, baseDotSize * canvasScale);
|
||||
|
||||
// 计算网格偏移(考虑画布偏移)
|
||||
const offsetX = canvasOffset.x % gridSize;
|
||||
const offsetY = canvasOffset.y % gridSize;
|
||||
|
||||
// 计算需要渲染的网格点数量
|
||||
const cols = Math.ceil(width / gridSize) + 2;
|
||||
const rows = Math.ceil(height / gridSize) + 2;
|
||||
|
||||
const dots: Array<{ x: number; y: number }> = [];
|
||||
|
||||
for (let i = -1; i < rows; i++) {
|
||||
for (let j = -1; j < cols; j++) {
|
||||
dots.push({
|
||||
x: j * gridSize + offsetX,
|
||||
y: i * gridSize + offsetY
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { dots, dotSize, gridSize };
|
||||
}, [canvasOffset, canvasScale, width, height]);
|
||||
|
||||
// 大网格(每5个小格一个大格)
|
||||
const majorGridPattern = useMemo(() => {
|
||||
const majorGridSize = gridPattern.gridSize * 5;
|
||||
const offsetX = canvasOffset.x % majorGridSize;
|
||||
const offsetY = canvasOffset.y % majorGridSize;
|
||||
|
||||
const lines: Array<{ type: 'h' | 'v'; pos: number }> = [];
|
||||
|
||||
// 垂直线
|
||||
const vCols = Math.ceil(width / majorGridSize) + 2;
|
||||
for (let i = -1; i < vCols; i++) {
|
||||
lines.push({
|
||||
type: 'v',
|
||||
pos: i * majorGridSize + offsetX
|
||||
});
|
||||
}
|
||||
|
||||
// 水平线
|
||||
const hRows = Math.ceil(height / majorGridSize) + 2;
|
||||
for (let i = -1; i < hRows; i++) {
|
||||
lines.push({
|
||||
type: 'h',
|
||||
pos: i * majorGridSize + offsetY
|
||||
});
|
||||
}
|
||||
|
||||
return lines;
|
||||
}, [canvasOffset, canvasScale, width, height, gridPattern.gridSize]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
>
|
||||
{/* 主网格线 */}
|
||||
{majorGridPattern.map((line, idx) => (
|
||||
line.type === 'v' ? (
|
||||
<line
|
||||
key={`v-${idx}`}
|
||||
x1={line.pos}
|
||||
y1={0}
|
||||
x2={line.pos}
|
||||
y2={height}
|
||||
stroke="rgba(255, 255, 255, 0.03)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
) : (
|
||||
<line
|
||||
key={`h-${idx}`}
|
||||
x1={0}
|
||||
y1={line.pos}
|
||||
x2={width}
|
||||
y2={line.pos}
|
||||
stroke="rgba(255, 255, 255, 0.03)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)
|
||||
))}
|
||||
|
||||
{/* 点阵网格 */}
|
||||
{gridPattern.dots.map((dot, idx) => (
|
||||
<circle
|
||||
key={idx}
|
||||
cx={dot.x}
|
||||
cy={dot.y}
|
||||
r={gridPattern.dotSize}
|
||||
fill="rgba(255, 255, 255, 0.15)"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { BehaviorTreeCanvas } from './BehaviorTreeCanvas';
|
||||
@@ -1,182 +0,0 @@
|
||||
import { React, useState, useRef, useEffect, type ReactNode, Icons } from '@esengine/editor-runtime';
|
||||
|
||||
const { GripVertical } = Icons;
|
||||
|
||||
interface DraggablePanelProps {
|
||||
title: string | ReactNode;
|
||||
icon?: ReactNode;
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
width?: number;
|
||||
maxHeight?: number;
|
||||
initialPosition?: { x: number; y: number };
|
||||
headerActions?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode | false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可拖动面板通用组件
|
||||
* 提供标题栏拖动、关闭按钮等基础功能
|
||||
*/
|
||||
export const DraggablePanel: React.FC<DraggablePanelProps> = ({
|
||||
title,
|
||||
icon,
|
||||
isVisible,
|
||||
onClose,
|
||||
width = 400,
|
||||
maxHeight = 600,
|
||||
initialPosition = { x: 20, y: 100 },
|
||||
headerActions,
|
||||
children,
|
||||
footer
|
||||
}) => {
|
||||
const [position, setPosition] = useState(initialPosition);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const newX = e.clientX - dragOffset.x;
|
||||
const newY = e.clientY - dragOffset.y;
|
||||
|
||||
// 限制面板在视口内
|
||||
const maxX = window.innerWidth - width;
|
||||
const maxY = window.innerHeight - 100;
|
||||
|
||||
setPosition({
|
||||
x: Math.max(0, Math.min(newX, maxX)),
|
||||
y: Math.max(0, Math.min(newY, maxY))
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, dragOffset, width]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!panelRef.current) return;
|
||||
|
||||
const rect = panelRef.current.getBoundingClientRect();
|
||||
setDragOffset({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
});
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: `${width}px`,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
backgroundColor: '#1e1e1e',
|
||||
border: '1px solid #3f3f3f',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
userSelect: isDragging ? 'none' : 'auto'
|
||||
}}
|
||||
>
|
||||
{/* 可拖动标题栏 */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #3f3f3f',
|
||||
backgroundColor: '#252525',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<GripVertical size={14} color="#666" style={{ flexShrink: 0 }} />
|
||||
{icon}
|
||||
{typeof title === 'string' ? (
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#fff'
|
||||
}}>
|
||||
{title}
|
||||
</span>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{headerActions}
|
||||
<button
|
||||
onClick={onClose}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 页脚 */}
|
||||
{footer && (
|
||||
<div style={{
|
||||
borderTop: '1px solid #3f3f3f',
|
||||
backgroundColor: '#252525',
|
||||
borderBottomLeftRadius: '8px',
|
||||
borderBottomRightRadius: '8px'
|
||||
}}>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
import { React, useMemo } from '@esengine/editor-runtime';
|
||||
import { ConnectionRenderer } from './ConnectionRenderer';
|
||||
import { ConnectionViewData } from '../../types';
|
||||
import { Node } from '../../domain/models/Node';
|
||||
import { Connection } from '../../domain/models/Connection';
|
||||
|
||||
interface ConnectionLayerProps {
|
||||
connections: Connection[];
|
||||
nodes: Node[];
|
||||
selectedConnection?: { from: string; to: string } | null;
|
||||
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
|
||||
onConnectionClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||
onConnectionContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||
}
|
||||
export const ConnectionLayer: React.FC<ConnectionLayerProps> = ({
|
||||
connections,
|
||||
nodes,
|
||||
selectedConnection,
|
||||
getPortPosition,
|
||||
onConnectionClick,
|
||||
onConnectionContextMenu
|
||||
}) => {
|
||||
const nodeMap = useMemo(() => {
|
||||
return new Map(nodes.map((node) => [node.id, node]));
|
||||
}, [nodes]);
|
||||
|
||||
const connectionViewData = useMemo(() => {
|
||||
return connections
|
||||
.map((connection) => {
|
||||
const fromNode = nodeMap.get(connection.from);
|
||||
const toNode = nodeMap.get(connection.to);
|
||||
|
||||
if (!fromNode || !toNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { connection, fromNode, toNode };
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
}, [connections, nodeMap]);
|
||||
|
||||
const isConnectionSelected = (connection: { from: string; to: string }) => {
|
||||
return selectedConnection?.from === connection.from &&
|
||||
selectedConnection?.to === connection.to;
|
||||
};
|
||||
|
||||
if (connectionViewData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="connection-layer"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
overflow: 'visible',
|
||||
zIndex: 0
|
||||
}}
|
||||
>
|
||||
<g style={{ pointerEvents: 'auto' }}>
|
||||
{connectionViewData.map(({ connection, fromNode, toNode }) => {
|
||||
const viewData: ConnectionViewData = {
|
||||
connection,
|
||||
isSelected: isConnectionSelected(connection)
|
||||
};
|
||||
return (
|
||||
<ConnectionRenderer
|
||||
key={`${connection.from}-${connection.to}`}
|
||||
connectionData={viewData}
|
||||
fromNode={fromNode}
|
||||
toNode={toNode}
|
||||
getPortPosition={getPortPosition}
|
||||
onClick={onConnectionClick}
|
||||
onContextMenu={onConnectionContextMenu}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -1,164 +0,0 @@
|
||||
import { React, useMemo } from '@esengine/editor-runtime';
|
||||
import { ConnectionViewData } from '../../types';
|
||||
import { Node } from '../../domain/models/Node';
|
||||
|
||||
interface ConnectionRendererProps {
|
||||
connectionData: ConnectionViewData;
|
||||
fromNode: Node;
|
||||
toNode: Node;
|
||||
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
|
||||
onClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
|
||||
}
|
||||
const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
|
||||
connectionData,
|
||||
fromNode,
|
||||
toNode,
|
||||
getPortPosition,
|
||||
onClick,
|
||||
onContextMenu
|
||||
}) => {
|
||||
const { connection, isSelected } = connectionData;
|
||||
|
||||
const pathData = useMemo(() => {
|
||||
let fromPos, toPos;
|
||||
|
||||
if (connection.connectionType === 'property') {
|
||||
// 属性连接:使用 fromProperty 和 toProperty
|
||||
fromPos = getPortPosition(connection.from, connection.fromProperty);
|
||||
toPos = getPortPosition(connection.to, connection.toProperty);
|
||||
} else {
|
||||
// 节点连接:使用输出和输入端口
|
||||
fromPos = getPortPosition(connection.from, undefined, 'output');
|
||||
toPos = getPortPosition(connection.to, undefined, 'input');
|
||||
}
|
||||
|
||||
if (!fromPos || !toPos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const x1 = fromPos.x;
|
||||
const y1 = fromPos.y;
|
||||
const x2 = toPos.x;
|
||||
const y2 = toPos.y;
|
||||
|
||||
let pathD: string;
|
||||
|
||||
if (connection.connectionType === 'property') {
|
||||
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: pathD,
|
||||
midX: (x1 + x2) / 2,
|
||||
midY: (y1 + y2) / 2
|
||||
};
|
||||
}, [connection, fromNode, toNode, getPortPosition]);
|
||||
|
||||
const isPropertyConnection = connection.connectionType === 'property';
|
||||
|
||||
const color = isPropertyConnection ? '#ab47bc' : '#00bcd4';
|
||||
const glowColor = isPropertyConnection ? 'rgba(171, 71, 188, 0.6)' : 'rgba(0, 188, 212, 0.6)';
|
||||
const strokeColor = isSelected ? '#FFD700' : color;
|
||||
const strokeWidth = isSelected ? 3.5 : 2.5;
|
||||
|
||||
const gradientId = `gradient-${connection.from}-${connection.to}`;
|
||||
|
||||
if (!pathData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pathD = pathData.path;
|
||||
const endPosMatch = pathD.match(/C [0-9.-]+ [0-9.-]+, [0-9.-]+ [0-9.-]+, ([0-9.-]+) ([0-9.-]+)/);
|
||||
const endX = endPosMatch ? parseFloat(endPosMatch[1]) : 0;
|
||||
const endY = endPosMatch ? parseFloat(endPosMatch[2]) : 0;
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e, connection.from, connection.to);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onContextMenu?.(e, connection.from, connection.to);
|
||||
};
|
||||
|
||||
return (
|
||||
<g
|
||||
className="connection"
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ cursor: 'pointer' }}
|
||||
data-connection-from={connection.from}
|
||||
data-connection-to={connection.to}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.8" />
|
||||
<stop offset="50%" stopColor={strokeColor} stopOpacity="1" />
|
||||
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path
|
||||
d={pathData.path}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={24}
|
||||
/>
|
||||
|
||||
<path
|
||||
d={pathData.path}
|
||||
fill="none"
|
||||
stroke={glowColor}
|
||||
strokeWidth={strokeWidth + 2}
|
||||
strokeLinecap="round"
|
||||
opacity={isSelected ? 0.4 : 0.2}
|
||||
/>
|
||||
|
||||
<path
|
||||
d={pathData.path}
|
||||
fill="none"
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
<circle
|
||||
cx={endX}
|
||||
cy={endY}
|
||||
r="5"
|
||||
fill={strokeColor}
|
||||
stroke="rgba(0, 0, 0, 0.3)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{isSelected && (
|
||||
<>
|
||||
<circle
|
||||
cx={pathData.midX}
|
||||
cy={pathData.midY}
|
||||
r="8"
|
||||
fill={strokeColor}
|
||||
opacity="0.3"
|
||||
/>
|
||||
<circle
|
||||
cx={pathData.midX}
|
||||
cy={pathData.midY}
|
||||
r="5"
|
||||
fill={strokeColor}
|
||||
stroke="rgba(0, 0, 0, 0.5)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectionRenderer = ConnectionRendererComponent;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ConnectionRenderer } from './ConnectionRenderer';
|
||||
export { ConnectionLayer } from './ConnectionLayer';
|
||||
@@ -1,95 +0,0 @@
|
||||
import { React, Icons } from '@esengine/editor-runtime';
|
||||
|
||||
const { Trash2, Replace, Plus } = Icons;
|
||||
|
||||
interface NodeContextMenuProps {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
nodeId: string | null;
|
||||
isBlackboardVariable?: boolean;
|
||||
onReplaceNode?: () => void;
|
||||
onDeleteNode?: () => void;
|
||||
onCreateNode?: () => void;
|
||||
}
|
||||
|
||||
export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
||||
visible,
|
||||
position,
|
||||
nodeId,
|
||||
isBlackboardVariable = false,
|
||||
onReplaceNode,
|
||||
onDeleteNode,
|
||||
onCreateNode
|
||||
}) => {
|
||||
if (!visible) return null;
|
||||
|
||||
const menuItemStyle = {
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
color: '#cccccc',
|
||||
fontSize: '13px',
|
||||
transition: 'background-color 0.15s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
backgroundColor: '#2d2d30',
|
||||
border: '1px solid #454545',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 10000,
|
||||
minWidth: '150px',
|
||||
padding: '4px 0'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{nodeId ? (
|
||||
<>
|
||||
{onReplaceNode && (
|
||||
<div
|
||||
onClick={onReplaceNode}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Replace size={14} />
|
||||
替换节点
|
||||
</div>
|
||||
)}
|
||||
{onDeleteNode && (
|
||||
<div
|
||||
onClick={onDeleteNode}
|
||||
style={{ ...menuItemStyle, color: '#f48771' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#5a1a1a'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除节点
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{onCreateNode && (
|
||||
<div
|
||||
onClick={onCreateNode}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
新建节点
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,350 +0,0 @@
|
||||
import { React, useRef, useEffect, useState, useMemo, Icons } from '@esengine/editor-runtime';
|
||||
import type { LucideIcon } from '@esengine/editor-runtime';
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { NodeFactory } from '../../infrastructure/factories/NodeFactory';
|
||||
|
||||
const { Search, X, ChevronDown, ChevronRight } = Icons;
|
||||
|
||||
interface QuickCreateMenuProps {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
searchText: string;
|
||||
selectedIndex: number;
|
||||
mode: 'create' | 'replace';
|
||||
iconMap: Record<string, LucideIcon>;
|
||||
onSearchChange: (text: string) => void;
|
||||
onIndexChange: (index: number) => void;
|
||||
onNodeSelect: (template: NodeTemplate) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CategoryGroup {
|
||||
category: string;
|
||||
templates: NodeTemplate[];
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
visible,
|
||||
position,
|
||||
searchText,
|
||||
selectedIndex,
|
||||
iconMap,
|
||||
onSearchChange,
|
||||
onIndexChange,
|
||||
onNodeSelect,
|
||||
onClose
|
||||
}) => {
|
||||
const selectedNodeRef = useRef<HTMLDivElement>(null);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
const [shouldAutoScroll, setShouldAutoScroll] = useState(false);
|
||||
|
||||
const nodeFactory = useMemo(() => new NodeFactory(), []);
|
||||
const allTemplates = useMemo(() => nodeFactory.getAllTemplates(), [nodeFactory]);
|
||||
const searchTextLower = searchText.toLowerCase();
|
||||
const filteredTemplates = searchTextLower
|
||||
? allTemplates.filter((t: NodeTemplate) => {
|
||||
const className = t.className || '';
|
||||
return t.displayName.toLowerCase().includes(searchTextLower) ||
|
||||
t.description.toLowerCase().includes(searchTextLower) ||
|
||||
t.category.toLowerCase().includes(searchTextLower) ||
|
||||
className.toLowerCase().includes(searchTextLower);
|
||||
})
|
||||
: allTemplates;
|
||||
|
||||
const categoryGroups: CategoryGroup[] = React.useMemo(() => {
|
||||
const groups = new Map<string, NodeTemplate[]>();
|
||||
|
||||
filteredTemplates.forEach((template: NodeTemplate) => {
|
||||
const category = template.category || '未分类';
|
||||
if (!groups.has(category)) {
|
||||
groups.set(category, []);
|
||||
}
|
||||
groups.get(category)!.push(template);
|
||||
});
|
||||
|
||||
return Array.from(groups.entries()).map(([category, templates]) => ({
|
||||
category,
|
||||
templates,
|
||||
isExpanded: searchTextLower ? true : expandedCategories.has(category)
|
||||
})).sort((a, b) => a.category.localeCompare(b.category));
|
||||
}, [filteredTemplates, expandedCategories, searchTextLower]);
|
||||
|
||||
const flattenedTemplates = React.useMemo(() => {
|
||||
return categoryGroups.flatMap((group) =>
|
||||
group.isExpanded ? group.templates : []
|
||||
);
|
||||
}, [categoryGroups]);
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
} else {
|
||||
newSet.add(category);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (allTemplates.length > 0 && expandedCategories.size === 0) {
|
||||
const categories = new Set(allTemplates.map((t) => t.category || '未分类'));
|
||||
setExpandedCategories(categories);
|
||||
}
|
||||
}, [allTemplates, expandedCategories.size]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoScroll && selectedNodeRef.current) {
|
||||
selectedNodeRef.current.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'smooth'
|
||||
});
|
||||
setShouldAutoScroll(false);
|
||||
}
|
||||
}, [selectedIndex, shouldAutoScroll]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
let globalIndex = -1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.quick-create-menu-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.quick-create-menu-list::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
.quick-create-menu-list::-webkit-scrollbar-thumb {
|
||||
background: #3c3c3c;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.quick-create-menu-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #4c4c4c;
|
||||
}
|
||||
.category-header {
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
.category-header:hover {
|
||||
background-color: #3c3c3c;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '300px',
|
||||
maxHeight: '500px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 搜索框 */}
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
borderBottom: '1px solid #3c3c3c',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<Search size={16} style={{ color: '#999', flexShrink: 0 }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索节点..."
|
||||
autoFocus
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
onSearchChange(e.target.value);
|
||||
onIndexChange(0);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setShouldAutoScroll(true);
|
||||
onIndexChange(Math.min(selectedIndex + 1, flattenedTemplates.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setShouldAutoScroll(true);
|
||||
onIndexChange(Math.max(selectedIndex - 1, 0));
|
||||
} else if (e.key === 'Enter' && flattenedTemplates.length > 0) {
|
||||
e.preventDefault();
|
||||
const selectedTemplate = flattenedTemplates[selectedIndex];
|
||||
if (selectedTemplate) {
|
||||
onNodeSelect(selectedTemplate);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#ccc',
|
||||
fontSize: '14px',
|
||||
padding: '4px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#999',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 节点列表 */}
|
||||
<div
|
||||
className="quick-create-menu-list"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '4px'
|
||||
}}
|
||||
>
|
||||
{categoryGroups.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
未找到匹配的节点
|
||||
</div>
|
||||
) : (
|
||||
categoryGroups.map((group) => {
|
||||
return (
|
||||
<div key={group.category} style={{ marginBottom: '4px' }}>
|
||||
<div
|
||||
className="category-header"
|
||||
onClick={() => toggleCategory(group.category)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
{group.isExpanded ? (
|
||||
<ChevronDown size={14} style={{ color: '#999', flexShrink: 0 }} />
|
||||
) : (
|
||||
<ChevronRight size={14} style={{ color: '#999', flexShrink: 0 }} />
|
||||
)}
|
||||
<span style={{
|
||||
color: '#aaa',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
flex: 1
|
||||
}}>
|
||||
{group.category}
|
||||
</span>
|
||||
<span style={{
|
||||
color: '#666',
|
||||
fontSize: '11px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '10px'
|
||||
}}>
|
||||
{group.templates.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{group.isExpanded && (
|
||||
<div style={{ paddingLeft: '8px', paddingTop: '4px' }}>
|
||||
{group.templates.map((template: NodeTemplate) => {
|
||||
globalIndex++;
|
||||
const IconComponent = template.icon ? iconMap[template.icon] : null;
|
||||
const className = template.className || '';
|
||||
const isSelected = globalIndex === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={template.className || template.displayName}
|
||||
ref={isSelected ? selectedNodeRef : null}
|
||||
onClick={() => onNodeSelect(template)}
|
||||
onMouseEnter={() => onIndexChange(globalIndex)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: isSelected ? '#0e639c' : '#1e1e1e',
|
||||
borderLeft: `3px solid ${template.color || '#666'}`,
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
transform: isSelected ? 'translateX(2px)' : 'translateX(0)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{IconComponent && (
|
||||
<IconComponent size={14} style={{ color: template.color || '#999', flexShrink: 0 }} />
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
color: '#ccc',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
marginBottom: '2px'
|
||||
}}>
|
||||
{template.displayName}
|
||||
</div>
|
||||
{className && (
|
||||
<div style={{
|
||||
color: '#666',
|
||||
fontSize: '10px',
|
||||
fontFamily: 'Consolas, Monaco, monospace',
|
||||
opacity: 0.8
|
||||
}}>
|
||||
{className}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#999',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{template.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,405 +0,0 @@
|
||||
import { React, Icons } from '@esengine/editor-runtime';
|
||||
import type { LucideIcon } from '@esengine/editor-runtime';
|
||||
import { PropertyDefinition } from '@esengine/behavior-tree';
|
||||
|
||||
import { Node as BehaviorTreeNodeType } from '../../domain/models/Node';
|
||||
import { Connection } from '../../domain/models/Connection';
|
||||
import { ROOT_NODE_ID } from '../../domain/constants/RootNode';
|
||||
import type { NodeExecutionStatus } from '../../stores';
|
||||
|
||||
import { BehaviorTreeExecutor } from '../../utils/BehaviorTreeExecutor';
|
||||
import { BlackboardValue } from '../../domain/models/Blackboard';
|
||||
|
||||
const { TreePine, Database, AlertTriangle, AlertCircle } = Icons;
|
||||
|
||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||
|
||||
interface BehaviorTreeNodeProps {
|
||||
node: BehaviorTreeNodeType;
|
||||
isSelected: boolean;
|
||||
isBeingDragged: boolean;
|
||||
dragDelta: { dx: number; dy: number };
|
||||
uncommittedNodeIds: Set<string>;
|
||||
blackboardVariables: BlackboardVariables;
|
||||
initialBlackboardVariables: BlackboardVariables;
|
||||
isExecuting: boolean;
|
||||
executionStatus?: NodeExecutionStatus;
|
||||
executionOrder?: number;
|
||||
connections: Connection[];
|
||||
nodes: BehaviorTreeNodeType[];
|
||||
executorRef: React.RefObject<BehaviorTreeExecutor | null>;
|
||||
iconMap: Record<string, LucideIcon>;
|
||||
draggingNodeId: string | null;
|
||||
onNodeClick: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
|
||||
onContextMenu: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
|
||||
onNodeMouseDown: (e: React.MouseEvent, nodeId: string) => void;
|
||||
onNodeMouseUpForConnection: (e: React.MouseEvent, nodeId: string) => void;
|
||||
onPortMouseDown: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
|
||||
onPortMouseUp: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
|
||||
}
|
||||
|
||||
const BehaviorTreeNodeComponent: React.FC<BehaviorTreeNodeProps> = ({
|
||||
node,
|
||||
isSelected,
|
||||
isBeingDragged,
|
||||
dragDelta,
|
||||
uncommittedNodeIds,
|
||||
blackboardVariables,
|
||||
initialBlackboardVariables,
|
||||
isExecuting,
|
||||
executionStatus,
|
||||
executionOrder,
|
||||
connections,
|
||||
nodes,
|
||||
executorRef,
|
||||
iconMap,
|
||||
draggingNodeId,
|
||||
onNodeClick,
|
||||
onContextMenu,
|
||||
onNodeMouseDown,
|
||||
onNodeMouseUpForConnection,
|
||||
onPortMouseDown,
|
||||
onPortMouseUp
|
||||
}) => {
|
||||
const isRoot = node.id === ROOT_NODE_ID;
|
||||
const isBlackboardVariable = node.data.nodeType === 'blackboard-variable';
|
||||
|
||||
const posX = node.position.x + (isBeingDragged ? dragDelta.dx : 0);
|
||||
const posY = node.position.y + (isBeingDragged ? dragDelta.dy : 0);
|
||||
|
||||
const isUncommitted = uncommittedNodeIds.has(node.id);
|
||||
const nodeClasses = [
|
||||
'bt-node',
|
||||
isSelected && 'selected',
|
||||
isRoot && 'root',
|
||||
isUncommitted && 'uncommitted',
|
||||
executionStatus && executionStatus !== 'idle' && executionStatus
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
data-node-id={node.id}
|
||||
className={nodeClasses}
|
||||
onClick={(e) => onNodeClick(e, node)}
|
||||
onContextMenu={(e) => onContextMenu(e, node)}
|
||||
onMouseDown={(e) => onNodeMouseDown(e, node.id)}
|
||||
onMouseUp={(e) => onNodeMouseUpForConnection(e, node.id)}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
style={{
|
||||
left: posX,
|
||||
top: posY,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
cursor: isRoot ? 'default' : (draggingNodeId === node.id ? 'grabbing' : 'grab'),
|
||||
transition: draggingNodeId === node.id ? 'none' : 'all 0.2s',
|
||||
zIndex: isRoot ? 50 : (draggingNodeId === node.id ? 100 : (isSelected ? 10 : 1))
|
||||
}}
|
||||
>
|
||||
{/* 执行顺序角标 - 使用绝对定位,不影响节点布局 */}
|
||||
{executionOrder !== undefined && (
|
||||
<div
|
||||
className="bt-node-execution-badge"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
backgroundColor: '#2196f3',
|
||||
color: '#fff',
|
||||
borderRadius: '50%',
|
||||
minWidth: '24px',
|
||||
height: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
padding: '0 6px',
|
||||
boxShadow: '0 2px 8px rgba(33, 150, 243, 0.5)',
|
||||
border: '2px solid #1a1a1d',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
title={`执行顺序: ${executionOrder}`}
|
||||
>
|
||||
{executionOrder}
|
||||
</div>
|
||||
)}
|
||||
{isBlackboardVariable ? (
|
||||
(() => {
|
||||
const varName = node.data.variableName as string;
|
||||
const currentValue = blackboardVariables[varName];
|
||||
const initialValue = initialBlackboardVariables[varName];
|
||||
const isModified = isExecuting && JSON.stringify(currentValue) !== JSON.stringify(initialValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bt-node-header blackboard">
|
||||
<Database size={16} className="bt-node-header-icon" />
|
||||
<div className="bt-node-header-title">
|
||||
{varName || 'Variable'}
|
||||
</div>
|
||||
{isModified && (
|
||||
<span style={{
|
||||
fontSize: '9px',
|
||||
color: '#ffbb00',
|
||||
backgroundColor: 'rgba(255, 187, 0, 0.2)',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '2px',
|
||||
marginLeft: '4px'
|
||||
}}>
|
||||
运行时
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bt-node-body">
|
||||
<div
|
||||
className="bt-node-blackboard-value"
|
||||
style={{
|
||||
backgroundColor: isModified ? 'rgba(255, 187, 0, 0.15)' : 'transparent',
|
||||
border: isModified ? '1px solid rgba(255, 187, 0, 0.3)' : 'none',
|
||||
borderRadius: '2px',
|
||||
padding: '2px 4px'
|
||||
}}
|
||||
title={isModified ? `初始值: ${JSON.stringify(initialValue)}\n当前值: ${JSON.stringify(currentValue)}` : undefined}
|
||||
>
|
||||
{JSON.stringify(currentValue)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-property="__value__"
|
||||
data-port-type="variable-output"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id, '__value__')}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id, '__value__')}
|
||||
className="bt-node-port bt-node-port-variable-output"
|
||||
title="Output"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<>
|
||||
<div className={`bt-node-header ${isRoot ? 'root' : (node.template.type || 'action')}`}>
|
||||
{isRoot ? (
|
||||
<TreePine size={16} className="bt-node-header-icon" />
|
||||
) : (
|
||||
node.template.icon && (() => {
|
||||
const IconComponent = iconMap[node.template.icon];
|
||||
return IconComponent ? (
|
||||
<IconComponent size={16} className="bt-node-header-icon" />
|
||||
) : (
|
||||
<span className="bt-node-header-icon">{node.template.icon}</span>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
<div className="bt-node-header-title">
|
||||
<div>{isRoot ? 'ROOT' : node.template.displayName}</div>
|
||||
<div className="bt-node-id" title={node.id}>
|
||||
#{node.id}
|
||||
</div>
|
||||
</div>
|
||||
{!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
|
||||
<div
|
||||
className="bt-node-missing-executor-warning"
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertCircle
|
||||
size={14}
|
||||
style={{
|
||||
color: '#f44336',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<div className="bt-node-missing-executor-tooltip">
|
||||
缺失执行器:找不到节点对应的执行器 "{node.template.className}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isUncommitted && (
|
||||
<div
|
||||
className="bt-node-uncommitted-warning"
|
||||
style={{
|
||||
marginLeft: (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) ? '4px' : 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
style={{
|
||||
color: '#ff5722',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<div className="bt-node-uncommitted-tooltip">
|
||||
未生效节点:运行时添加的节点,需重新运行才能生效
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isRoot && !isUncommitted && node.template.type === 'composite' &&
|
||||
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) &&
|
||||
!nodes.some((n) =>
|
||||
connections.some((c) => c.from === node.id && c.to === n.id)
|
||||
) && (
|
||||
<div
|
||||
className="bt-node-empty-warning-container"
|
||||
style={{
|
||||
marginLeft: ((!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) || isUncommitted) ? '4px' : 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
pointerEvents: 'auto',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
style={{
|
||||
color: '#ff9800',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
<div className="bt-node-empty-warning-tooltip">
|
||||
空节点:没有子节点,执行时会直接跳过
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bt-node-body">
|
||||
{!isRoot && (
|
||||
<div className="bt-node-category">
|
||||
{node.template.category}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.template.properties.length > 0 && (
|
||||
<div className="bt-node-properties">
|
||||
{node.template.properties.map((prop: PropertyDefinition, idx: number) => {
|
||||
const hasConnection = connections.some(
|
||||
(conn: Connection) => conn.toProperty === prop.name && conn.to === node.id
|
||||
);
|
||||
const propValue = node.data[prop.name];
|
||||
|
||||
return (
|
||||
<div key={idx} className="bt-node-property">
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-property={prop.name}
|
||||
data-port-type="property-input"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id, prop.name)}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id, prop.name)}
|
||||
className={`bt-node-port bt-node-port-property ${hasConnection ? 'connected' : ''}`}
|
||||
title={prop.description || prop.name}
|
||||
/>
|
||||
<span
|
||||
className="bt-node-property-label"
|
||||
title={prop.description}
|
||||
>
|
||||
{prop.name}:
|
||||
</span>
|
||||
{propValue !== undefined && (
|
||||
<span className="bt-node-property-value">
|
||||
{String(propValue)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isRoot && (
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-port-type="node-input"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id)}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id)}
|
||||
className="bt-node-port bt-node-port-input"
|
||||
title="Input"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isRoot || node.template.type === 'composite' || node.template.type === 'decorator') &&
|
||||
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) && (
|
||||
<div
|
||||
data-port="true"
|
||||
data-node-id={node.id}
|
||||
data-port-type="node-output"
|
||||
onMouseDown={(e) => onPortMouseDown(e, node.id)}
|
||||
onMouseUp={(e) => onPortMouseUp(e, node.id)}
|
||||
className="bt-node-port bt-node-port-output"
|
||||
title="Output"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用 React.memo 优化节点组件性能
|
||||
* 只在关键 props 变化时重新渲染
|
||||
*/
|
||||
export const BehaviorTreeNode = React.memo(BehaviorTreeNodeComponent, (prevProps, nextProps) => {
|
||||
// 如果节点本身变化,需要重新渲染
|
||||
if (prevProps.node.id !== nextProps.node.id ||
|
||||
prevProps.node.position.x !== nextProps.node.position.x ||
|
||||
prevProps.node.position.y !== nextProps.node.position.y ||
|
||||
prevProps.node.template.className !== nextProps.node.template.className) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prevProps.isSelected !== nextProps.isSelected ||
|
||||
prevProps.isBeingDragged !== nextProps.isBeingDragged ||
|
||||
prevProps.executionStatus !== nextProps.executionStatus ||
|
||||
prevProps.executionOrder !== nextProps.executionOrder ||
|
||||
prevProps.draggingNodeId !== nextProps.draggingNodeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果正在被拖拽,且 dragDelta 变化,需要重新渲染
|
||||
if (nextProps.isBeingDragged &&
|
||||
(prevProps.dragDelta.dx !== nextProps.dragDelta.dx ||
|
||||
prevProps.dragDelta.dy !== nextProps.dragDelta.dy)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果执行状态变化,需要重新渲染
|
||||
if (prevProps.isExecuting !== nextProps.isExecuting) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查 uncommittedNodeIds 中是否包含当前节点
|
||||
const prevUncommitted = prevProps.uncommittedNodeIds.has(nextProps.node.id);
|
||||
const nextUncommitted = nextProps.uncommittedNodeIds.has(nextProps.node.id);
|
||||
if (prevUncommitted !== nextUncommitted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 节点数据变化时需要重新渲染
|
||||
if (JSON.stringify(prevProps.node.data) !== JSON.stringify(nextProps.node.data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 其他情况不重新渲染
|
||||
return true;
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
import { React, useMemo, Icons } from '@esengine/editor-runtime';
|
||||
import type { LucideIcon } from '@esengine/editor-runtime';
|
||||
|
||||
import { NodeViewData } from '../../types';
|
||||
|
||||
const LucideIcons = Icons;
|
||||
|
||||
/**
|
||||
* 图标映射
|
||||
*/
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
TreePine: LucideIcons.TreePine,
|
||||
GitBranch: LucideIcons.GitBranch,
|
||||
Shuffle: LucideIcons.Shuffle,
|
||||
Repeat: LucideIcons.Repeat,
|
||||
RotateCcw: LucideIcons.RotateCcw,
|
||||
FlipHorizontal: LucideIcons.FlipHorizontal,
|
||||
CheckCircle: LucideIcons.CheckCircle,
|
||||
XCircle: LucideIcons.XCircle,
|
||||
Play: LucideIcons.Play,
|
||||
Pause: LucideIcons.Pause,
|
||||
Square: LucideIcons.Square,
|
||||
Circle: LucideIcons.Circle,
|
||||
Diamond: LucideIcons.Diamond,
|
||||
Box: LucideIcons.Box,
|
||||
Flag: LucideIcons.Flag,
|
||||
Target: LucideIcons.Target
|
||||
};
|
||||
|
||||
/**
|
||||
* 节点渲染器属性
|
||||
*/
|
||||
interface BehaviorTreeNodeRendererProps {
|
||||
/**
|
||||
* 节点视图数据
|
||||
*/
|
||||
nodeData: NodeViewData;
|
||||
|
||||
/**
|
||||
* 节点点击事件
|
||||
*/
|
||||
onClick?: (e: React.MouseEvent, nodeId: string) => void;
|
||||
|
||||
/**
|
||||
* 节点双击事件
|
||||
*/
|
||||
onDoubleClick?: (e: React.MouseEvent, nodeId: string) => void;
|
||||
|
||||
/**
|
||||
* 节点右键事件
|
||||
*/
|
||||
onContextMenu?: (e: React.MouseEvent, nodeId: string) => void;
|
||||
|
||||
/**
|
||||
* 鼠标按下事件
|
||||
*/
|
||||
onMouseDown?: (e: React.MouseEvent, nodeId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 行为树节点渲染器
|
||||
* 负责单个节点的渲染
|
||||
*/
|
||||
export const BehaviorTreeNodeRenderer: React.FC<BehaviorTreeNodeRendererProps> = ({
|
||||
nodeData,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
onMouseDown
|
||||
}) => {
|
||||
const { node, isSelected, isDragging, executionStatus } = nodeData;
|
||||
const { template, position } = node;
|
||||
|
||||
const IconComponent = iconMap[template.icon || 'Box'] || LucideIcons.Box;
|
||||
|
||||
const nodeStyle = useMemo(() => {
|
||||
let borderColor = template.color || '#4a9eff';
|
||||
const backgroundColor = '#2a2a2a';
|
||||
let boxShadow = 'none';
|
||||
|
||||
if (isSelected) {
|
||||
boxShadow = `0 0 0 2px ${borderColor}`;
|
||||
}
|
||||
|
||||
if (executionStatus === 'running') {
|
||||
borderColor = '#ffa500';
|
||||
boxShadow = `0 0 10px ${borderColor}`;
|
||||
} else if (executionStatus === 'success') {
|
||||
borderColor = '#00ff00';
|
||||
} else if (executionStatus === 'failure') {
|
||||
borderColor = '#ff0000';
|
||||
}
|
||||
|
||||
return {
|
||||
position: 'absolute' as const,
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
minWidth: '180px',
|
||||
padding: '12px',
|
||||
backgroundColor,
|
||||
borderRadius: '8px',
|
||||
border: `2px solid ${borderColor}`,
|
||||
boxShadow,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none' as const,
|
||||
transition: 'box-shadow 0.2s',
|
||||
opacity: isDragging ? 0.7 : 1
|
||||
};
|
||||
}, [template.color, position, isSelected, isDragging, executionStatus]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(e, node.id);
|
||||
};
|
||||
|
||||
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDoubleClick?.(e, node.id);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onContextMenu?.(e, node.id);
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onMouseDown?.(e, node.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="behavior-tree-node"
|
||||
style={nodeStyle}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseDown={handleMouseDown}
|
||||
data-node-id={node.id}
|
||||
>
|
||||
{/* 节点头部 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
<IconComponent size={20} color={template.color || '#4a9eff'} />
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#ffffff',
|
||||
flex: 1
|
||||
}}>
|
||||
{template.displayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 节点类型 */}
|
||||
{template.category && (
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#888888',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{template.category}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 节点描述 */}
|
||||
{template.description && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#cccccc',
|
||||
marginTop: '8px',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{template.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入连接点 */}
|
||||
<div
|
||||
className="node-input-pin"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '-6px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: template.color || '#4a9eff',
|
||||
border: '2px solid #1a1a1a',
|
||||
transform: 'translateY(-50%)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
data-pin-type="input"
|
||||
data-node-id={node.id}
|
||||
/>
|
||||
|
||||
{/* 输出连接点 */}
|
||||
<div
|
||||
className="node-output-pin"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
right: '-6px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: template.color || '#4a9eff',
|
||||
border: '2px solid #1a1a1a',
|
||||
transform: 'translateY(-50%)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
data-pin-type="output"
|
||||
data-node-id={node.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { BehaviorTreeNodeRenderer } from './BehaviorTreeNodeRenderer';
|
||||
@@ -1,126 +0,0 @@
|
||||
/* 行为树编辑器面板样式 */
|
||||
.behavior-tree-editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.behavior-tree-editor-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
color: #444;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-state .hint {
|
||||
font-size: 12px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
background-color: #2d2d30;
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-center,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
color: #cccccc;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: #cccccc;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover:not(:disabled) {
|
||||
background-color: #3e3e42;
|
||||
border-color: #464647;
|
||||
}
|
||||
|
||||
.toolbar-btn:active:not(:disabled) {
|
||||
background-color: #2a2d2e;
|
||||
}
|
||||
|
||||
.toolbar-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 画布容器 */
|
||||
.editor-canvas-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 节点层 */
|
||||
.nodes-layer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 行为树画布 */
|
||||
.behavior-tree-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-content {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
import {
|
||||
React,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
Core,
|
||||
createLogger,
|
||||
MessageHub,
|
||||
open,
|
||||
save,
|
||||
Icons,
|
||||
} from '@esengine/editor-runtime';
|
||||
import { useBehaviorTreeDataStore } from '../../stores';
|
||||
import { BehaviorTreeEditor } from '../BehaviorTreeEditor';
|
||||
import { BehaviorTreeService } from '../../services/BehaviorTreeService';
|
||||
import { showToast } from '../../services/NotificationService';
|
||||
import { Node as BehaviorTreeNode } from '../../domain/models/Node';
|
||||
import { BehaviorTree } from '../../domain/models/BehaviorTree';
|
||||
import './BehaviorTreeEditorPanel.css';
|
||||
|
||||
const { FolderOpen } = Icons;
|
||||
|
||||
const logger = createLogger('BehaviorTreeEditorPanel');
|
||||
|
||||
/**
|
||||
* 行为树编辑器面板组件
|
||||
* 提供完整的行为树编辑功能,包括:
|
||||
* - 节点的创建、删除、移动
|
||||
* - 连接管理
|
||||
* - 黑板变量管理
|
||||
* - 文件保存和加载
|
||||
*/
|
||||
interface BehaviorTreeEditorPanelProps {
|
||||
/** 项目路径,用于文件系统操作 */
|
||||
projectPath?: string | null;
|
||||
/** 导出对话框打开回调 */
|
||||
onOpenExportDialog?: () => void;
|
||||
/** 获取可用文件列表回调 */
|
||||
onGetAvailableFiles?: () => string[];
|
||||
}
|
||||
|
||||
export const BehaviorTreeEditorPanel: React.FC<BehaviorTreeEditorPanelProps> = ({
|
||||
projectPath,
|
||||
onOpenExportDialog
|
||||
// onGetAvailableFiles - 保留用于未来的批量导出功能
|
||||
}) => {
|
||||
const isOpen = useBehaviorTreeDataStore((state) => state.isOpen);
|
||||
const blackboardVariables = useBehaviorTreeDataStore((state) => state.blackboardVariables);
|
||||
|
||||
// 文件状态管理
|
||||
const [currentFilePath, setCurrentFilePath] = useState<string | null>(null);
|
||||
const [currentFileName, setCurrentFileName] = useState<string>('');
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string>('');
|
||||
|
||||
// 监听树的变化来检测未保存更改
|
||||
const tree = useBehaviorTreeDataStore((state) => state.tree);
|
||||
const storeFilePath = useBehaviorTreeDataStore((state) => state.currentFilePath);
|
||||
const storeFileName = useBehaviorTreeDataStore((state) => state.currentFileName);
|
||||
|
||||
// 初始化时从 store 读取文件信息(解决时序问题)
|
||||
useEffect(() => {
|
||||
if (storeFilePath && !currentFilePath) {
|
||||
setCurrentFilePath(storeFilePath);
|
||||
setCurrentFileName(storeFileName);
|
||||
const loadedTree = useBehaviorTreeDataStore.getState().tree;
|
||||
setLastSavedSnapshot(JSON.stringify(loadedTree));
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
}, [storeFilePath, storeFileName, currentFilePath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && lastSavedSnapshot) {
|
||||
const currentSnapshot = JSON.stringify(tree);
|
||||
setHasUnsavedChanges(currentSnapshot !== lastSavedSnapshot);
|
||||
}
|
||||
}, [tree, lastSavedSnapshot, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
|
||||
// 订阅文件打开事件
|
||||
const unsubscribeFileOpened = messageHub.subscribe('behavior-tree:file-opened', (data: { filePath: string; fileName: string }) => {
|
||||
setCurrentFilePath(data.filePath);
|
||||
setCurrentFileName(data.fileName);
|
||||
const loadedTree = useBehaviorTreeDataStore.getState().tree;
|
||||
setLastSavedSnapshot(JSON.stringify(loadedTree));
|
||||
setHasUnsavedChanges(false);
|
||||
});
|
||||
|
||||
// 订阅节点属性更改事件
|
||||
const unsubscribePropertyChanged = messageHub.subscribe('behavior-tree:node-property-changed',
|
||||
(data: { nodeId: string; propertyName: string; value: any }) => {
|
||||
const state = useBehaviorTreeDataStore.getState();
|
||||
const node = state.getNode(data.nodeId);
|
||||
|
||||
if (node) {
|
||||
const newData = { ...node.data, [data.propertyName]: data.value };
|
||||
|
||||
// 更新节点数据
|
||||
const updatedNode = new BehaviorTreeNode(
|
||||
node.id,
|
||||
node.template,
|
||||
newData,
|
||||
node.position,
|
||||
Array.from(node.children)
|
||||
);
|
||||
|
||||
// 更新树
|
||||
const nodes = state.getNodes().map((n) =>
|
||||
n.id === data.nodeId ? updatedNode : n
|
||||
);
|
||||
|
||||
const newTree = new BehaviorTree(
|
||||
nodes,
|
||||
state.getConnections(),
|
||||
state.getBlackboard(),
|
||||
state.getRootNodeId()
|
||||
);
|
||||
|
||||
state.setTree(newTree);
|
||||
setHasUnsavedChanges(true);
|
||||
|
||||
// 强制刷新画布
|
||||
state.triggerForceUpdate();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribeFileOpened();
|
||||
unsubscribePropertyChanged();
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to subscribe to events:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNodeSelect = useCallback((node: BehaviorTreeNode) => {
|
||||
try {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
messageHub.publish('behavior-tree:node-selected', { data: node });
|
||||
} catch (error) {
|
||||
logger.error('Failed to publish node selection:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
let filePath = currentFilePath;
|
||||
|
||||
if (!filePath) {
|
||||
const selected = await save({
|
||||
filters: [{ name: 'Behavior Tree', extensions: ['btree'] }],
|
||||
defaultPath: projectPath || undefined,
|
||||
title: '保存行为树'
|
||||
});
|
||||
|
||||
if (!selected) return;
|
||||
filePath = selected;
|
||||
}
|
||||
|
||||
const service = Core.services.resolve(BehaviorTreeService);
|
||||
await service.saveToFile(filePath);
|
||||
|
||||
setCurrentFilePath(filePath);
|
||||
const fileName = filePath.split(/[\\/]/).pop()?.replace('.btree', '') || 'Untitled';
|
||||
setCurrentFileName(fileName);
|
||||
setLastSavedSnapshot(JSON.stringify(tree));
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
showToast(`文件已保存: ${fileName}.btree`, 'success');
|
||||
} catch (error) {
|
||||
logger.error('Failed to save file:', error);
|
||||
showToast(`保存失败: ${error}`, 'error');
|
||||
}
|
||||
}, [currentFilePath, projectPath, tree]);
|
||||
|
||||
const handleOpen = useCallback(async () => {
|
||||
try {
|
||||
if (hasUnsavedChanges) {
|
||||
const confirmed = window.confirm('当前文件有未保存的更改,是否继续打开新文件?');
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const selected = await open({
|
||||
filters: [{ name: 'Behavior Tree', extensions: ['btree'] }],
|
||||
multiple: false,
|
||||
directory: false,
|
||||
defaultPath: projectPath || undefined,
|
||||
title: '打开行为树'
|
||||
});
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
const filePath = selected as string;
|
||||
const service = Core.services.resolve(BehaviorTreeService);
|
||||
await service.loadFromFile(filePath);
|
||||
|
||||
setCurrentFilePath(filePath);
|
||||
const fileName = filePath.split(/[\\/]/).pop()?.replace('.btree', '') || 'Untitled';
|
||||
setCurrentFileName(fileName);
|
||||
|
||||
const loadedTree = useBehaviorTreeDataStore.getState().tree;
|
||||
setLastSavedSnapshot(JSON.stringify(loadedTree));
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
showToast(`文件已打开: ${fileName}.btree`, 'success');
|
||||
} catch (error) {
|
||||
logger.error('Failed to open file:', error);
|
||||
showToast(`打开失败: ${error}`, 'error');
|
||||
}
|
||||
}, [hasUnsavedChanges, projectPath]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (onOpenExportDialog) {
|
||||
onOpenExportDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
messageHub.publish('compiler:open-dialog', {
|
||||
compilerId: 'behavior-tree',
|
||||
currentFileName: currentFileName || undefined,
|
||||
projectPath: projectPath || undefined
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to open export dialog:', error);
|
||||
showToast(`无法打开导出对话框: ${error}`, 'error');
|
||||
}
|
||||
}, [onOpenExportDialog, currentFileName, projectPath]);
|
||||
|
||||
const handleCopyToClipboard = useCallback(async () => {
|
||||
try {
|
||||
const store = useBehaviorTreeDataStore.getState();
|
||||
const metadata = { name: currentFileName || 'Untitled', description: '' };
|
||||
const jsonContent = store.exportToJSON(metadata);
|
||||
|
||||
await navigator.clipboard.writeText(jsonContent);
|
||||
showToast('已复制到剪贴板', 'success');
|
||||
} catch (error) {
|
||||
logger.error('Failed to copy to clipboard:', error);
|
||||
showToast(`复制失败: ${error}`, 'error');
|
||||
}
|
||||
}, [currentFileName]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
if (e.ctrlKey && e.key === 'o') {
|
||||
e.preventDefault();
|
||||
handleOpen();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleSave, handleOpen]);
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div className="behavior-tree-editor-empty">
|
||||
<div className="empty-state">
|
||||
<FolderOpen size={48} />
|
||||
<p>No behavior tree file opened</p>
|
||||
<p className="hint">Double-click a .btree file to edit</p>
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#0e639c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
打开文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="behavior-tree-editor-panel">
|
||||
<BehaviorTreeEditor
|
||||
blackboardVariables={blackboardVariables}
|
||||
projectPath={projectPath}
|
||||
showToolbar={true}
|
||||
currentFileName={currentFileName}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onSave={handleSave}
|
||||
onOpen={handleOpen}
|
||||
onExport={handleExport}
|
||||
onCopyToClipboard={handleCopyToClipboard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
.behavior-tree-properties-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.properties-panel-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.properties-panel-tabs .tab-button {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.properties-panel-tabs .tab-button:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.properties-panel-tabs .tab-button.active {
|
||||
color: var(--primary-color);
|
||||
border-bottom-color: var(--primary-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.properties-panel-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,479 +0,0 @@
|
||||
import { React, Icons } from '@esengine/editor-runtime';
|
||||
|
||||
const { Play, Pause, Square, SkipForward, Undo, Redo, ZoomIn, Save, FolderOpen, Download, Clipboard, Home } = Icons;
|
||||
|
||||
type ExecutionMode = 'idle' | 'running' | 'paused';
|
||||
|
||||
interface EditorToolbarProps {
|
||||
executionMode: ExecutionMode;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
hasUnsavedChanges?: boolean;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onStop: () => void;
|
||||
onStep: () => void;
|
||||
onReset: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onResetView: () => void;
|
||||
onSave?: () => void;
|
||||
onOpen?: () => void;
|
||||
onExport?: () => void;
|
||||
onCopyToClipboard?: () => void;
|
||||
onGoToRoot?: () => void;
|
||||
}
|
||||
|
||||
export const EditorToolbar: React.FC<EditorToolbarProps> = ({
|
||||
executionMode,
|
||||
canUndo,
|
||||
canRedo,
|
||||
hasUnsavedChanges = false,
|
||||
onPlay,
|
||||
onPause,
|
||||
onStop,
|
||||
onStep,
|
||||
onReset,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onResetView,
|
||||
onSave,
|
||||
onOpen,
|
||||
onExport,
|
||||
onCopyToClipboard,
|
||||
onGoToRoot
|
||||
}) => {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
backgroundColor: '#2a2a2a',
|
||||
padding: '6px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
||||
border: '1px solid #3f3f3f',
|
||||
zIndex: 100
|
||||
}}>
|
||||
{/* 文件操作组 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
padding: '2px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
borderRadius: '6px'
|
||||
}}>
|
||||
{onOpen && (
|
||||
<button
|
||||
onClick={onOpen}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
title="打开文件 (Ctrl+O)"
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onSave && (
|
||||
<button
|
||||
onClick={onSave}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
backgroundColor: hasUnsavedChanges ? '#2563eb' : '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: hasUnsavedChanges ? '#fff' : '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
title={`保存 (Ctrl+S)${hasUnsavedChanges ? ' - 有未保存的更改' : ''}`}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = hasUnsavedChanges ? '#1d4ed8' : '#4a4a4a'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = hasUnsavedChanges ? '#2563eb' : '#3c3c3c'}
|
||||
>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onExport && (
|
||||
<button
|
||||
onClick={onExport}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
title="导出运行时配置"
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onCopyToClipboard && (
|
||||
<button
|
||||
onClick={onCopyToClipboard}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
title="复制JSON到剪贴板"
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
|
||||
>
|
||||
<Clipboard size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div style={{
|
||||
width: '1px',
|
||||
backgroundColor: '#444',
|
||||
margin: '2px 0'
|
||||
}} />
|
||||
|
||||
{/* 执行控制组 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
padding: '2px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
borderRadius: '6px'
|
||||
}}>
|
||||
{/* 播放按钮 */}
|
||||
<button
|
||||
onClick={onPlay}
|
||||
disabled={executionMode === 'running'}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
backgroundColor: executionMode === 'running' ? '#2a2a2a' : '#16a34a',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: executionMode === 'running' ? '#666' : '#fff',
|
||||
cursor: executionMode === 'running' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
title="运行 (Play)"
|
||||
onMouseEnter={(e) => {
|
||||
if (executionMode !== 'running') {
|
||||
e.currentTarget.style.backgroundColor = '#15803d';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (executionMode !== 'running') {
|
||||
e.currentTarget.style.backgroundColor = '#16a34a';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
{/* 暂停按钮 */}
|
||||
<button
|
||||
onClick={onPause}
|
||||
disabled={executionMode === 'idle'}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
backgroundColor: executionMode === 'idle' ? '#2a2a2a' : '#f59e0b',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: executionMode === 'idle' ? '#666' : '#fff',
|
||||
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
title={executionMode === 'paused' ? '继续' : '暂停'}
|
||||
onMouseEnter={(e) => {
|
||||
if (executionMode !== 'idle') {
|
||||
e.currentTarget.style.backgroundColor = '#d97706';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (executionMode !== 'idle') {
|
||||
e.currentTarget.style.backgroundColor = '#f59e0b';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{executionMode === 'paused' ? <Play size={14} fill="currentColor" /> : <Pause size={14} fill="currentColor" />}
|
||||
</button>
|
||||
|
||||
{/* 停止按钮 */}
|
||||
<button
|
||||
onClick={onStop}
|
||||
disabled={executionMode === 'idle'}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
backgroundColor: executionMode === 'idle' ? '#2a2a2a' : '#dc2626',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: executionMode === 'idle' ? '#666' : '#fff',
|
||||
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
title="停止"
|
||||
onMouseEnter={(e) => {
|
||||
if (executionMode !== 'idle') {
|
||||
e.currentTarget.style.backgroundColor = '#b91c1c';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (executionMode !== 'idle') {
|
||||
e.currentTarget.style.backgroundColor = '#dc2626';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
|
||||
{/* 单步执行按钮 */}
|
||||
<button
|
||||
onClick={onStep}
|
||||
disabled={executionMode !== 'idle' && executionMode !== 'paused'}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
backgroundColor: (executionMode !== 'idle' && executionMode !== 'paused') ? '#2a2a2a' : '#3b82f6',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: (executionMode !== 'idle' && executionMode !== 'paused') ? '#666' : '#fff',
|
||||
cursor: (executionMode !== 'idle' && executionMode !== 'paused') ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
title="单步执行"
|
||||
onMouseEnter={(e) => {
|
||||
if (executionMode === 'idle' || executionMode === 'paused') {
|
||||
e.currentTarget.style.backgroundColor = '#2563eb';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (executionMode === 'idle' || executionMode === 'paused') {
|
||||
e.currentTarget.style.backgroundColor = '#3b82f6';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SkipForward size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div style={{
|
||||
width: '1px',
|
||||
backgroundColor: '#444',
|
||||
margin: '2px 0'
|
||||
}} />
|
||||
|
||||
{/* 视图控制 */}
|
||||
<button
|
||||
onClick={onResetView}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
title="重置视图 (滚轮缩放, Alt+拖动平移)"
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
|
||||
>
|
||||
<ZoomIn size={13} />
|
||||
<span>Reset View</span>
|
||||
</button>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div style={{
|
||||
width: '1px',
|
||||
backgroundColor: '#444',
|
||||
margin: '2px 0'
|
||||
}} />
|
||||
|
||||
{/* 历史控制组 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
padding: '2px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
borderRadius: '6px'
|
||||
}}>
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
backgroundColor: canUndo ? '#3c3c3c' : '#2a2a2a',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: canUndo ? '#ccc' : '#666',
|
||||
cursor: canUndo ? 'pointer' : 'not-allowed',
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
title="撤销 (Ctrl+Z)"
|
||||
onMouseEnter={(e) => {
|
||||
if (canUndo) {
|
||||
e.currentTarget.style.backgroundColor = '#4a4a4a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (canUndo) {
|
||||
e.currentTarget.style.backgroundColor = '#3c3c3c';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Undo size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
backgroundColor: canRedo ? '#3c3c3c' : '#2a2a2a',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: canRedo ? '#ccc' : '#666',
|
||||
cursor: canRedo ? 'pointer' : 'not-allowed',
|
||||
fontSize: '13px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
title="重做 (Ctrl+Shift+Z / Ctrl+Y)"
|
||||
onMouseEnter={(e) => {
|
||||
if (canRedo) {
|
||||
e.currentTarget.style.backgroundColor = '#4a4a4a';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (canRedo) {
|
||||
e.currentTarget.style.backgroundColor = '#3c3c3c';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Redo size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 状态指示器 */}
|
||||
<div style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#1e1e1e',
|
||||
borderRadius: '6px',
|
||||
fontSize: '11px',
|
||||
color: '#999',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontWeight: 500,
|
||||
minWidth: '70px'
|
||||
}}>
|
||||
<span style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor:
|
||||
executionMode === 'running' ? '#16a34a' :
|
||||
executionMode === 'paused' ? '#f59e0b' : '#666',
|
||||
boxShadow: executionMode !== 'idle' ? `0 0 8px ${
|
||||
executionMode === 'running' ? '#16a34a' :
|
||||
executionMode === 'paused' ? '#f59e0b' : 'transparent'
|
||||
}` : 'none',
|
||||
transition: 'all 0.2s'
|
||||
}} />
|
||||
<span style={{
|
||||
color: executionMode === 'running' ? '#16a34a' :
|
||||
executionMode === 'paused' ? '#f59e0b' : '#888'
|
||||
}}>
|
||||
{executionMode === 'idle' ? 'Idle' :
|
||||
executionMode === 'running' ? 'Running' : 'Paused'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{onGoToRoot && (
|
||||
<>
|
||||
<div style={{
|
||||
width: '1px',
|
||||
backgroundColor: '#444',
|
||||
margin: '2px 0'
|
||||
}} />
|
||||
<button
|
||||
onClick={onGoToRoot}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
backgroundColor: '#3c3c3c',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
transition: 'all 0.15s'
|
||||
}}
|
||||
title="回到根节点"
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
|
||||
>
|
||||
<Home size={13} />
|
||||
<span>Root</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user