refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 (#216)
* refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 * feat(editor): 添加插件市场功能 * feat(editor): 重构插件市场以支持版本管理和ZIP打包 * feat(editor): 重构插件发布流程并修复React渲染警告 * fix(plugin): 修复插件发布和市场的路径不一致问题 * feat: 重构插件发布流程并添加插件删除功能 * fix(editor): 完善插件删除功能并修复多个关键问题 * fix(auth): 修复自动登录与手动登录的竞态条件问题 * feat(editor): 重构插件管理流程 * feat(editor): 支持 ZIP 文件直接发布插件 - 新增 PluginSourceParser 解析插件源 - 重构发布流程支持文件夹和 ZIP 两种方式 - 优化发布向导 UI * feat(editor): 插件市场支持多版本安装 - 插件解压到项目 plugins 目录 - 新增 Tauri 后端安装/卸载命令 - 支持选择任意版本安装 - 修复打包逻辑,保留完整 dist 目录结构 * feat(editor): 个人中心支持多版本管理 - 合并同一插件的不同版本 - 添加版本历史展开/折叠功能 - 禁止有待审核 PR 时更新插件 * fix(editor): 修复 InspectorRegistry 服务注册 - InspectorRegistry 实现 IService 接口 - 注册到 Core.services 供插件使用 * feat(behavior-tree-editor): 完善插件注册和文件操作 - 添加文件创建模板和操作处理器 - 实现右键菜单创建行为树功能 - 修复文件读取权限问题(使用 Tauri 命令) - 添加 BehaviorTreeEditorPanel 组件 - 修复 rollup 配置支持动态导入 * feat(plugin): 完善插件构建和发布流程 * fix(behavior-tree-editor): 完整恢复编辑器并修复 Toast 集成 * fix(behavior-tree-editor): 修复节点选中、连线跟随和文件加载问题并优化性能 * fix(behavior-tree-editor): 修复端口连接失败问题并优化连线样式 * refactor(behavior-tree-editor): 移除调试面板功能简化代码结构 * refactor(behavior-tree-editor): 清理冗余代码合并重复逻辑 * feat(behavior-tree-editor): 完善编辑器核心功能增强扩展性 * fix(lint): 修复ESLint错误确保CI通过 * refactor(behavior-tree-editor): 优化编辑器工具栏和编译器功能 * refactor(behavior-tree-editor): 清理技术债务,优化代码质量 * fix(editor-app): 修复字符串替换安全问题
This commit is contained in:
-196
@@ -1,196 +0,0 @@
|
||||
import React, { useRef, useCallback, forwardRef } from 'react';
|
||||
import { useCanvasInteraction } from '../../../hooks/useCanvasInteraction';
|
||||
import { EditorConfig } from '../../../types';
|
||||
|
||||
/**
|
||||
* 画布组件属性
|
||||
*/
|
||||
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 || internalRef;
|
||||
|
||||
const {
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
isPanning,
|
||||
handleWheel,
|
||||
startPanning,
|
||||
updatePanning,
|
||||
stopPanning
|
||||
} = useCanvasInteraction();
|
||||
|
||||
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 && (
|
||||
<div
|
||||
className="canvas-grid"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255,255,255,0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.05) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: `${config.gridSize * canvasScale}px ${config.gridSize * canvasScale}px`,
|
||||
backgroundPosition: `${canvasOffset.x}px ${canvasOffset.y}px`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 内容容器(应用变换) */}
|
||||
<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 +0,0 @@
|
||||
export { BehaviorTreeCanvas } from './BehaviorTreeCanvas';
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
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;
|
||||
}
|
||||
|
||||
const isSelected = selectedConnection?.from === connection.from &&
|
||||
selectedConnection?.to === connection.to;
|
||||
|
||||
const viewData: ConnectionViewData = {
|
||||
connection,
|
||||
isSelected
|
||||
};
|
||||
|
||||
return { viewData, fromNode, toNode };
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
}, [connections, nodeMap, selectedConnection]);
|
||||
|
||||
if (connectionViewData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="connection-layer"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
overflow: 'visible'
|
||||
}}
|
||||
>
|
||||
<g style={{ pointerEvents: 'auto' }}>
|
||||
{connectionViewData.map(({ viewData, fromNode, toNode }) => (
|
||||
<ConnectionRenderer
|
||||
key={`${viewData.connection.from}-${viewData.connection.to}`}
|
||||
connectionData={viewData}
|
||||
fromNode={fromNode}
|
||||
toNode={toNode}
|
||||
getPortPosition={getPortPosition}
|
||||
onClick={onConnectionClick}
|
||||
onContextMenu={onConnectionContextMenu}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
-175
@@ -1,175 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连线渲染器
|
||||
* 使用贝塞尔曲线渲染节点间的连接
|
||||
*/
|
||||
export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
|
||||
connectionData,
|
||||
fromNode,
|
||||
toNode,
|
||||
getPortPosition,
|
||||
onClick,
|
||||
onContextMenu
|
||||
}) => {
|
||||
const { connection, isSelected } = connectionData;
|
||||
|
||||
const pathData = useMemo(() => {
|
||||
let fromPos, toPos;
|
||||
|
||||
if (connection.connectionType === 'property') {
|
||||
// 属性连接:从DOM获取实际引脚位置
|
||||
fromPos = getPortPosition(connection.from);
|
||||
toPos = getPortPosition(connection.to, connection.toProperty);
|
||||
} else {
|
||||
// 节点连接:使用DOM获取端口位置
|
||||
fromPos = getPortPosition(connection.from, undefined, 'output');
|
||||
toPos = getPortPosition(connection.to, undefined, 'input');
|
||||
}
|
||||
|
||||
if (!fromPos || !toPos) {
|
||||
// 如果DOM还没渲染,返回null
|
||||
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 color = connection.connectionType === 'property' ? '#9c27b0' : '#0e639c';
|
||||
const strokeColor = isSelected ? '#FFD700' : color;
|
||||
const strokeWidth = isSelected ? 4 : 2;
|
||||
const markerId = `arrowhead-${connection.from}-${connection.to}`;
|
||||
|
||||
if (!pathData) {
|
||||
// DOM还没渲染完成,跳过此连接
|
||||
return null;
|
||||
}
|
||||
|
||||
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}
|
||||
>
|
||||
{/* 透明的宽线条,用于更容易点击 */}
|
||||
<path
|
||||
d={pathData.path}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={20}
|
||||
/>
|
||||
|
||||
{/* 箭头标记定义 */}
|
||||
<defs>
|
||||
<marker
|
||||
id={markerId}
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
refX="9"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
markerUnits="strokeWidth"
|
||||
>
|
||||
<polygon
|
||||
points="0 0, 10 3, 0 6"
|
||||
fill={strokeColor}
|
||||
/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* 实际显示的线条 */}
|
||||
<path
|
||||
d={pathData.path}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
markerEnd={`url(#${markerId})`}
|
||||
/>
|
||||
|
||||
{/* 选中时显示的中点 */}
|
||||
{isSelected && (
|
||||
<circle
|
||||
cx={pathData.midX}
|
||||
cy={pathData.midY}
|
||||
r="5"
|
||||
fill={strokeColor}
|
||||
stroke="#1a1a1a"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ConnectionRenderer } from './ConnectionRenderer';
|
||||
export { ConnectionLayer } from './ConnectionLayer';
|
||||
-346
@@ -1,346 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
TreePine,
|
||||
Database,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { PropertyDefinition } from '@esengine/behavior-tree';
|
||||
import { BehaviorTreeNode as BehaviorTreeNodeType, Connection, ROOT_NODE_ID, NodeExecutionStatus } from '../../../../stores/behaviorTreeStore';
|
||||
import { BehaviorTreeExecutor } from '../../../../utils/BehaviorTreeExecutor';
|
||||
import { BlackboardValue } from '../../../../domain/models/Blackboard';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const BehaviorTreeNode: 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)}
|
||||
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))
|
||||
}}
|
||||
>
|
||||
{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-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>
|
||||
{executionOrder !== undefined && (
|
||||
<div
|
||||
className="bt-node-execution-order"
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
backgroundColor: '#2196f3',
|
||||
color: '#fff',
|
||||
borderRadius: '50%',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0
|
||||
}}
|
||||
title={`执行顺序: ${executionOrder}`}
|
||||
>
|
||||
{executionOrder}
|
||||
</div>
|
||||
)}
|
||||
{!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
|
||||
<div
|
||||
className="bt-node-missing-executor-warning"
|
||||
style={{
|
||||
marginLeft: executionOrder !== undefined ? '4px' : '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: (executionOrder !== undefined || (!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: (executionOrder !== undefined || (!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>
|
||||
);
|
||||
};
|
||||
-219
@@ -1,219 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { NodeViewData } from '../../../types';
|
||||
|
||||
/**
|
||||
* 图标映射
|
||||
*/
|
||||
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';
|
||||
-283
@@ -1,283 +0,0 @@
|
||||
.behavior-tree-editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
/* 全屏模式 */
|
||||
.behavior-tree-editor-panel.fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
/* 全屏模式下的工具栏 - 确保不透明 */
|
||||
.behavior-tree-editor-panel.fullscreen .behavior-tree-editor-toolbar {
|
||||
background: #252526;
|
||||
opacity: 1;
|
||||
position: relative;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.behavior-tree-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: #3e3e42;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #cccccc;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-btn:hover:not(:disabled) {
|
||||
background: #2a2d2e;
|
||||
border-color: #3e3e42;
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-btn:active:not(:disabled) {
|
||||
background: #37373d;
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 执行控制按钮颜色 */
|
||||
.behavior-tree-editor-toolbar .toolbar-btn.btn-play {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-btn.btn-play:hover:not(:disabled) {
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
border-color: rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-btn.btn-pause {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-btn.btn-pause:hover:not(:disabled) {
|
||||
background: rgba(255, 152, 0, 0.15);
|
||||
border-color: rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-btn.btn-stop {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-btn.btn-stop:hover:not(:disabled) {
|
||||
background: rgba(244, 67, 54, 0.15);
|
||||
border-color: rgba(244, 67, 54, 0.3);
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-btn.btn-step {
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-btn.btn-step:hover:not(:disabled) {
|
||||
background: rgba(33, 150, 243, 0.15);
|
||||
border-color: rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
|
||||
/* 速率控制 */
|
||||
.speed-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.speed-control .speed-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.speed-control .speed-slider {
|
||||
width: 100px;
|
||||
height: 6px;
|
||||
background: #3c3c3c;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.speed-control .speed-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #0e639c;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 0 2px rgba(14, 99, 156, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.speed-control .speed-slider::-webkit-slider-thumb:hover {
|
||||
background: #1177bb;
|
||||
box-shadow: 0 0 0 4px rgba(14, 99, 156, 0.3);
|
||||
}
|
||||
|
||||
.speed-control .speed-slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #0e639c;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 0 0 2px rgba(14, 99, 156, 0.3);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.speed-control .speed-slider::-moz-range-thumb:hover {
|
||||
background: #1177bb;
|
||||
box-shadow: 0 0 0 4px rgba(14, 99, 156, 0.3);
|
||||
}
|
||||
|
||||
.speed-control .speed-value {
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
min-width: 35px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 文件信息 */
|
||||
.behavior-tree-editor-toolbar .file-info {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .file-name {
|
||||
font-size: 13px;
|
||||
color: #9d9d9d;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.behavior-tree-editor-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-canvas-area {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 黑板侧边栏 - 浮动面板 */
|
||||
.blackboard-sidebar {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 16px;
|
||||
bottom: 16px;
|
||||
width: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #252526;
|
||||
border: 1px solid #3e3e42;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.blackboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.blackboard-header .close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #9d9d9d;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.blackboard-header .close-btn:hover {
|
||||
background: #2a2d2e;
|
||||
border-color: #3e3e42;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.blackboard-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 黑板切换按钮 */
|
||||
.blackboard-toggle-btn {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 48px;
|
||||
background: #252526;
|
||||
border: 1px solid #3e3e42;
|
||||
border-radius: 8px 0 0 8px;
|
||||
cursor: pointer;
|
||||
color: #9d9d9d;
|
||||
transition: all 0.2s;
|
||||
z-index: 99;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.blackboard-toggle-btn:hover {
|
||||
background: #2a2d2e;
|
||||
color: #cccccc;
|
||||
width: 36px;
|
||||
}
|
||||
-771
@@ -1,771 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Save, FolderOpen, Download, Play, Pause, Square, SkipForward, Clipboard, ChevronRight, ChevronLeft, Copy, Home, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open, message } from '@tauri-apps/plugin-dialog';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { BehaviorTreeEditor } from '../../../../components/BehaviorTreeEditor';
|
||||
import { BehaviorTreeBlackboard } from '../../../../components/BehaviorTreeBlackboard';
|
||||
import { ExportRuntimeDialog, type ExportOptions } from '../../../../components/ExportRuntimeDialog';
|
||||
import { BehaviorTreeNameDialog } from '../../../../components/BehaviorTreeNameDialog';
|
||||
import { useToast } from '../../../../components/Toast';
|
||||
import { useBehaviorTreeStore, ROOT_NODE_ID } from '../../../../stores/behaviorTreeStore';
|
||||
import { EditorFormatConverter, BehaviorTreeAssetSerializer, GlobalBlackboardService, type BlackboardValueType } from '@esengine/behavior-tree';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import { LocalBlackboardTypeGenerator } from '../../../../generators/LocalBlackboardTypeGenerator';
|
||||
import { GlobalBlackboardTypeGenerator } from '../../../../generators/GlobalBlackboardTypeGenerator';
|
||||
import { useExecutionController } from '../../../hooks/useExecutionController';
|
||||
import { behaviorTreeFileService } from '../../../../services/BehaviorTreeFileService';
|
||||
import './BehaviorTreeEditorPanel.css';
|
||||
|
||||
const logger = createLogger('BehaviorTreeEditorPanel');
|
||||
|
||||
interface BehaviorTreeEditorPanelProps {
|
||||
projectPath?: string | null;
|
||||
}
|
||||
|
||||
export const BehaviorTreeEditorPanel: React.FC<BehaviorTreeEditorPanelProps> = ({ projectPath: propProjectPath }) => {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
pendingFilePath,
|
||||
setPendingFilePath,
|
||||
nodes,
|
||||
connections,
|
||||
exportToJSON,
|
||||
exportToRuntimeAsset,
|
||||
blackboardVariables,
|
||||
setBlackboardVariables,
|
||||
updateBlackboardVariable,
|
||||
initialBlackboardVariables,
|
||||
setInitialBlackboardVariables,
|
||||
isExecuting,
|
||||
setIsExecuting,
|
||||
saveNodesDataSnapshot,
|
||||
restoreNodesData,
|
||||
resetView
|
||||
} = useBehaviorTreeStore();
|
||||
|
||||
const [currentFilePath, setCurrentFilePath] = useState<string | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [projectPath, setProjectPath] = useState<string>('');
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||
const [availableBTreeFiles, setAvailableBTreeFiles] = useState<string[]>([]);
|
||||
const [isBlackboardOpen, setIsBlackboardOpen] = useState(true);
|
||||
const [globalVariables, setGlobalVariables] = useState<Record<string, any>>({});
|
||||
const [hasUnsavedGlobalChanges, setHasUnsavedGlobalChanges] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const isInitialMount = useRef(true);
|
||||
const initialStateSnapshot = useRef<{ nodes: number; variables: number }>({ nodes: 0, variables: 0 });
|
||||
const processingFileRef = useRef<string | null>(null);
|
||||
|
||||
const {
|
||||
executionMode,
|
||||
executionSpeed,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleStop,
|
||||
handleStep,
|
||||
handleSpeedChange
|
||||
} = useExecutionController({
|
||||
rootNodeId: ROOT_NODE_ID,
|
||||
projectPath: projectPath || '',
|
||||
blackboardVariables,
|
||||
nodes,
|
||||
connections,
|
||||
initialBlackboardVariables,
|
||||
onBlackboardUpdate: setBlackboardVariables,
|
||||
onInitialBlackboardSave: setInitialBlackboardVariables,
|
||||
onExecutingChange: setIsExecuting,
|
||||
onSaveNodesDataSnapshot: saveNodesDataSnapshot,
|
||||
onRestoreNodesData: restoreNodesData
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const initProject = async () => {
|
||||
if (propProjectPath) {
|
||||
setProjectPath(propProjectPath);
|
||||
localStorage.setItem('ecs-project-path', propProjectPath);
|
||||
await loadGlobalBlackboard(propProjectPath);
|
||||
} else {
|
||||
const savedPath = localStorage.getItem('ecs-project-path');
|
||||
if (savedPath) {
|
||||
setProjectPath(savedPath);
|
||||
await loadGlobalBlackboard(savedPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
initProject();
|
||||
}, [propProjectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAvailableFiles = async () => {
|
||||
if (projectPath) {
|
||||
try {
|
||||
const files = await invoke<string[]>('scan_behavior_trees', { projectPath });
|
||||
setAvailableBTreeFiles(files);
|
||||
} catch (error) {
|
||||
logger.error('加载行为树文件列表失败', error);
|
||||
setAvailableBTreeFiles([]);
|
||||
}
|
||||
} else {
|
||||
setAvailableBTreeFiles([]);
|
||||
}
|
||||
};
|
||||
loadAvailableFiles();
|
||||
}, [projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateGlobalVariables = () => {
|
||||
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||
const allVars = globalBlackboard.getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
};
|
||||
|
||||
updateGlobalVariables();
|
||||
const interval = setInterval(updateGlobalVariables, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentNodes = nodes.length;
|
||||
const currentVariables = Object.keys(blackboardVariables).length;
|
||||
|
||||
if (currentNodes !== initialStateSnapshot.current.nodes ||
|
||||
currentVariables !== initialStateSnapshot.current.variables) {
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}, [nodes, blackboardVariables]);
|
||||
|
||||
const loadFile = useCallback(async (filePath: string) => {
|
||||
const result = await behaviorTreeFileService.loadFile(filePath);
|
||||
|
||||
if (result.success && result.fileName) {
|
||||
setCurrentFilePath(filePath);
|
||||
setHasUnsavedChanges(false);
|
||||
isInitialMount.current = true;
|
||||
initialStateSnapshot.current = { nodes: 0, variables: 0 };
|
||||
showToast(`已打开 ${result.fileName}`, 'success');
|
||||
} else if (result.error) {
|
||||
showToast(`加载失败: ${result.error}`, 'error');
|
||||
}
|
||||
}, [showToast]);
|
||||
|
||||
// 使用 useLayoutEffect 处理 pendingFilePath(同步执行,DOM 更新前)
|
||||
// 这是文件加载的唯一入口,避免重复
|
||||
useLayoutEffect(() => {
|
||||
if (!pendingFilePath) return;
|
||||
|
||||
// 防止 React StrictMode 导致的重复执行
|
||||
if (processingFileRef.current === pendingFilePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
processingFileRef.current = pendingFilePath;
|
||||
|
||||
loadFile(pendingFilePath).then(() => {
|
||||
setPendingFilePath(null);
|
||||
processingFileRef.current = null;
|
||||
});
|
||||
}, [pendingFilePath, loadFile, setPendingFilePath]);
|
||||
|
||||
const loadGlobalBlackboard = async (path: string) => {
|
||||
try {
|
||||
const json = await invoke<string>('read_global_blackboard', { projectPath: path });
|
||||
const config = JSON.parse(json);
|
||||
Core.services.resolve(GlobalBlackboardService).importConfig(config);
|
||||
|
||||
const allVars = Core.services.resolve(GlobalBlackboardService).getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
setHasUnsavedGlobalChanges(false);
|
||||
logger.info('全局黑板配置已加载');
|
||||
} catch (error) {
|
||||
logger.error('加载全局黑板配置失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveGlobalBlackboard = async () => {
|
||||
if (!projectPath) {
|
||||
logger.error('未设置项目路径,无法保存全局黑板配置');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||
const json = globalBlackboard.toJSON();
|
||||
await invoke('write_global_blackboard', { projectPath, content: json });
|
||||
setHasUnsavedGlobalChanges(false);
|
||||
logger.info('全局黑板配置已保存到', `${projectPath}/.ecs/global-blackboard.json`);
|
||||
showToast('全局黑板已保存', 'success');
|
||||
} catch (error) {
|
||||
logger.error('保存全局黑板配置失败', error);
|
||||
showToast('保存全局黑板失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{
|
||||
name: 'Behavior Tree',
|
||||
extensions: ['btree']
|
||||
}]
|
||||
});
|
||||
|
||||
if (selected) {
|
||||
const result = await behaviorTreeFileService.loadFile(selected as string);
|
||||
if (result.success && result.fileName) {
|
||||
setCurrentFilePath(selected as string);
|
||||
setHasUnsavedChanges(false);
|
||||
isInitialMount.current = true;
|
||||
initialStateSnapshot.current = { nodes: 0, variables: 0 };
|
||||
showToast(`已打开 ${result.fileName}`, 'success');
|
||||
} else if (result.error) {
|
||||
showToast(`加载失败: ${result.error}`, 'error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('加载失败', error);
|
||||
showToast(`加载失败: ${error}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (isExecuting) {
|
||||
const confirmed = window.confirm(
|
||||
'行为树正在运行中。保存将使用设计时的初始值,运行时修改的黑板变量不会被保存。\n\n是否继续保存?'
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const saveFilePath = currentFilePath;
|
||||
|
||||
if (!saveFilePath) {
|
||||
if (!projectPath) {
|
||||
logger.error('未设置项目路径,无法保存行为树');
|
||||
await message('请先打开项目', { title: '错误', kind: 'error' });
|
||||
return;
|
||||
}
|
||||
setIsSaveDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await saveToFile(saveFilePath);
|
||||
} catch (error) {
|
||||
logger.error('保存失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveToFile = async (filePath: string) => {
|
||||
try {
|
||||
const json = exportToJSON({ name: 'behavior-tree', description: '' });
|
||||
await invoke('write_behavior_tree_file', { filePath, content: json });
|
||||
logger.info('行为树已保存', filePath);
|
||||
|
||||
setCurrentFilePath(filePath);
|
||||
setHasUnsavedChanges(false);
|
||||
isInitialMount.current = true;
|
||||
|
||||
const fileName = filePath.split(/[\\/]/).pop()?.replace('.btree', '') || '行为树';
|
||||
showToast(`${fileName} 已保存`, 'success');
|
||||
} catch (error) {
|
||||
logger.error('保存失败', error);
|
||||
showToast(`保存失败: ${error}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDialogConfirm = async (name: string) => {
|
||||
setIsSaveDialogOpen(false);
|
||||
try {
|
||||
const filePath = `${projectPath}/.ecs/behaviors/${name}.btree`;
|
||||
await saveToFile(filePath);
|
||||
|
||||
const files = await invoke<string[]>('scan_behavior_trees', { projectPath });
|
||||
setAvailableBTreeFiles(files);
|
||||
} catch (error) {
|
||||
logger.error('保存失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportRuntime = () => {
|
||||
setIsExportDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDoExport = async (options: ExportOptions) => {
|
||||
if (options.mode === 'workspace') {
|
||||
await handleExportWorkspace(options);
|
||||
} else {
|
||||
const fileName = options.selectedFiles[0];
|
||||
if (!fileName) {
|
||||
logger.error('没有可导出的文件');
|
||||
return;
|
||||
}
|
||||
const format = options.fileFormats.get(fileName) || 'binary';
|
||||
await handleExportSingle(fileName, format, options.assetOutputPath, options.typeOutputPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportSingle = async (fileName: string, format: 'json' | 'binary', outputPath: string, typeOutputPath: string) => {
|
||||
try {
|
||||
const extension = format === 'binary' ? 'bin' : 'json';
|
||||
const filePath = `${outputPath}/${fileName}.btree.${extension}`;
|
||||
|
||||
const data = exportToRuntimeAsset(
|
||||
{ name: fileName, description: 'Runtime behavior tree asset' },
|
||||
format
|
||||
);
|
||||
|
||||
await invoke('create_directory', { path: outputPath });
|
||||
|
||||
if (format === 'binary') {
|
||||
await invoke('write_binary_file', { filePath, content: Array.from(data as Uint8Array) });
|
||||
} else {
|
||||
await invoke('write_file_content', { path: filePath, content: data as string });
|
||||
}
|
||||
|
||||
logger.info(`运行时资产已导出 (${format})`, filePath);
|
||||
|
||||
await generateTypeScriptTypes(fileName, typeOutputPath);
|
||||
|
||||
showToast(`${fileName} 导出成功`, 'success');
|
||||
} catch (error) {
|
||||
logger.error('导出失败', error);
|
||||
showToast(`导出失败: ${error}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const generateTypeScriptTypes = async (assetId: string, outputPath: string): Promise<void> => {
|
||||
try {
|
||||
const sourceFilePath = `${projectPath}/.ecs/behaviors/${assetId}.btree`;
|
||||
const editorJson = await invoke<string>('read_file_content', { path: sourceFilePath });
|
||||
|
||||
const editorFormat = JSON.parse(editorJson);
|
||||
const blackboard = editorFormat.blackboard || {};
|
||||
|
||||
const tsCode = LocalBlackboardTypeGenerator.generate(blackboard, {
|
||||
behaviorTreeName: assetId,
|
||||
includeConstants: true,
|
||||
includeDefaults: true,
|
||||
includeHelpers: true
|
||||
});
|
||||
|
||||
const tsFilePath = `${outputPath}/${assetId}.ts`;
|
||||
await invoke('create_directory', { path: outputPath });
|
||||
await invoke('write_file_content', {
|
||||
path: tsFilePath,
|
||||
content: tsCode
|
||||
});
|
||||
|
||||
logger.info(`TypeScript 类型定义已生成: ${assetId}.ts`);
|
||||
} catch (error) {
|
||||
logger.error(`生成 TypeScript 类型定义失败: ${assetId}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyBehaviorTree = () => {
|
||||
const buildNodeTree = (nodeId: string, depth: number = 0): string => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return '';
|
||||
|
||||
const indent = ' '.repeat(depth);
|
||||
const childrenText = node.children.length > 0
|
||||
? `\n${node.children.map((childId) => buildNodeTree(childId, depth + 1)).join('\n')}`
|
||||
: '';
|
||||
|
||||
const propertiesText = Object.keys(node.data).length > 0
|
||||
? ` [${Object.entries(node.data).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}]`
|
||||
: '';
|
||||
|
||||
return `${indent}- ${node.template.displayName} (${node.template.type})${propertiesText}${childrenText}`;
|
||||
};
|
||||
|
||||
const rootNode = nodes.find((n) => n.id === ROOT_NODE_ID);
|
||||
if (!rootNode) {
|
||||
showToast('未找到根节点', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const treeStructure = `
|
||||
行为树结构
|
||||
==========
|
||||
文件: ${currentFilePath || '未保存'}
|
||||
节点总数: ${nodes.length}
|
||||
连接总数: ${connections.length}
|
||||
|
||||
节点树:
|
||||
${buildNodeTree(ROOT_NODE_ID)}
|
||||
|
||||
黑板变量 (${Object.keys(blackboardVariables).length}个):
|
||||
${Object.entries(blackboardVariables).map(([key, value]) => ` - ${key}: ${JSON.stringify(value)}`).join('\n') || ' 无'}
|
||||
|
||||
全部节点详情:
|
||||
${nodes.filter((n) => n.id !== ROOT_NODE_ID).map((node) => {
|
||||
const incoming = connections.filter((c) => c.to === node.id);
|
||||
const outgoing = connections.filter((c) => c.from === node.id);
|
||||
return `
|
||||
[${node.template.displayName}]
|
||||
类型: ${node.template.type}
|
||||
分类: ${node.template.category}
|
||||
类名: ${node.template.className || '无'}
|
||||
ID: ${node.id}
|
||||
子节点: ${node.children.length}个
|
||||
输入连接: ${incoming.length}个${incoming.length > 0 ? '\n ' + incoming.map((c) => {
|
||||
const fromNode = nodes.find((n) => n.id === c.from);
|
||||
return `← ${fromNode?.template.displayName || '未知'}`;
|
||||
}).join('\n ') : ''}
|
||||
输出连接: ${outgoing.length}个${outgoing.length > 0 ? '\n ' + outgoing.map((c) => {
|
||||
const toNode = nodes.find((n) => n.id === c.to);
|
||||
return `→ ${toNode?.template.displayName || '未知'}`;
|
||||
}).join('\n ') : ''}
|
||||
属性: ${JSON.stringify(node.data, null, 4)}`;
|
||||
}).join('\n')}
|
||||
`.trim();
|
||||
|
||||
navigator.clipboard.writeText(treeStructure).then(() => {
|
||||
showToast('行为树结构已复制到剪贴板', 'success');
|
||||
}).catch(() => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = treeStructure;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
showToast('行为树结构已复制到剪贴板', 'success');
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportWorkspace = async (options: ExportOptions) => {
|
||||
if (!projectPath) {
|
||||
logger.error('未设置项目路径');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const assetOutputDir = options.assetOutputPath;
|
||||
|
||||
if (options.selectedFiles.length === 0) {
|
||||
logger.warn('没有选择要导出的文件');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`开始导出 ${options.selectedFiles.length} 个文件...`);
|
||||
|
||||
for (const assetId of options.selectedFiles) {
|
||||
try {
|
||||
const format = options.fileFormats.get(assetId) || 'binary';
|
||||
const extension = format === 'binary' ? 'bin' : 'json';
|
||||
|
||||
const sourceFilePath = `${projectPath}/.ecs/behaviors/${assetId}.btree`;
|
||||
const editorJson = await invoke<string>('read_file_content', { path: sourceFilePath });
|
||||
|
||||
const editorFormat = JSON.parse(editorJson);
|
||||
|
||||
const asset = EditorFormatConverter.toAsset(editorFormat, {
|
||||
name: assetId,
|
||||
description: editorFormat.metadata?.description || ''
|
||||
});
|
||||
|
||||
const data = BehaviorTreeAssetSerializer.serialize(asset, {
|
||||
format,
|
||||
pretty: format === 'json',
|
||||
validate: true
|
||||
});
|
||||
|
||||
const outputFilePath = `${assetOutputDir}/${assetId}.btree.${extension}`;
|
||||
|
||||
const outputDir2 = outputFilePath.substring(0, outputFilePath.lastIndexOf('/'));
|
||||
await invoke('create_directory', { path: outputDir2 });
|
||||
|
||||
if (format === 'binary') {
|
||||
await invoke('write_binary_file', {
|
||||
filePath: outputFilePath,
|
||||
content: Array.from(data as Uint8Array)
|
||||
});
|
||||
} else {
|
||||
await invoke('write_file_content', {
|
||||
path: outputFilePath,
|
||||
content: data as string
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`导出成功: ${assetId} (${format})`);
|
||||
|
||||
await generateTypeScriptTypes(assetId, options.typeOutputPath);
|
||||
} catch (error) {
|
||||
logger.error(`导出失败: ${assetId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||
const config = globalBlackboard.exportConfig();
|
||||
const tsCode = GlobalBlackboardTypeGenerator.generate(config);
|
||||
const globalTsFilePath = `${options.typeOutputPath}/GlobalBlackboard.ts`;
|
||||
|
||||
await invoke('write_file_content', {
|
||||
path: globalTsFilePath,
|
||||
content: tsCode
|
||||
});
|
||||
|
||||
logger.info('全局变量类型定义已生成:', globalTsFilePath);
|
||||
} catch (error) {
|
||||
logger.error('导出全局变量类型定义失败', error);
|
||||
}
|
||||
|
||||
logger.info(`工作区导出完成: ${assetOutputDir}`);
|
||||
showToast('工作区导出成功', 'success');
|
||||
} catch (error) {
|
||||
logger.error('工作区导出失败', error);
|
||||
showToast(`工作区导出失败: ${error}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleVariableChange = (key: string, value: any) => {
|
||||
updateBlackboardVariable(key, value);
|
||||
};
|
||||
|
||||
const handleVariableAdd = (key: string, value: any) => {
|
||||
updateBlackboardVariable(key, value);
|
||||
};
|
||||
|
||||
const handleVariableDelete = (key: string) => {
|
||||
const newVars = { ...blackboardVariables };
|
||||
delete newVars[key];
|
||||
setBlackboardVariables(newVars);
|
||||
};
|
||||
|
||||
const handleVariableRename = (oldKey: string, newKey: string) => {
|
||||
if (oldKey === newKey) return;
|
||||
const newVars = { ...blackboardVariables };
|
||||
const value = newVars[oldKey];
|
||||
delete newVars[oldKey];
|
||||
newVars[newKey] = value;
|
||||
setBlackboardVariables(newVars);
|
||||
};
|
||||
|
||||
const handleGlobalVariableChange = (key: string, value: any) => {
|
||||
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||
globalBlackboard.setValue(key, value, true);
|
||||
const allVars = globalBlackboard.getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
setHasUnsavedGlobalChanges(true);
|
||||
};
|
||||
|
||||
const handleGlobalVariableAdd = (key: string, value: any, type: BlackboardValueType) => {
|
||||
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||
globalBlackboard.defineVariable(key, type, value);
|
||||
const allVars = globalBlackboard.getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
setHasUnsavedGlobalChanges(true);
|
||||
};
|
||||
|
||||
const handleGlobalVariableDelete = (key: string) => {
|
||||
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||
globalBlackboard.removeVariable(key);
|
||||
const allVars = globalBlackboard.getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
setHasUnsavedGlobalChanges(true);
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const newFullscreenState = !isFullscreen;
|
||||
setIsFullscreen(newFullscreenState);
|
||||
|
||||
// 通知主界面切换全屏状态
|
||||
messageHub?.publish('editor:fullscreen', { fullscreen: newFullscreenState });
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`behavior-tree-editor-panel ${isFullscreen ? 'fullscreen' : ''}`}>
|
||||
<div className="behavior-tree-editor-toolbar">
|
||||
{/* 文件操作 */}
|
||||
<div className="toolbar-section">
|
||||
<button onClick={handleOpen} className="toolbar-btn" title="打开">
|
||||
<FolderOpen size={16} />
|
||||
</button>
|
||||
<button onClick={handleSave} className="toolbar-btn" title="保存">
|
||||
<Save size={16} />
|
||||
</button>
|
||||
<button onClick={handleExportRuntime} className="toolbar-btn" title="导出运行时资产">
|
||||
<Download size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
{/* 执行控制 */}
|
||||
<div className="toolbar-section">
|
||||
{executionMode === 'idle' || executionMode === 'step' ? (
|
||||
<button onClick={handlePlay} className="toolbar-btn btn-play" title="开始执行">
|
||||
<Play size={16} />
|
||||
</button>
|
||||
) : executionMode === 'paused' ? (
|
||||
<button onClick={handlePlay} className="toolbar-btn btn-play" title="继续">
|
||||
<Play size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handlePause} className="toolbar-btn btn-pause" title="暂停">
|
||||
<Pause size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleStop} className="toolbar-btn btn-stop" title="停止" disabled={executionMode === 'idle'}>
|
||||
<Square size={16} />
|
||||
</button>
|
||||
<button onClick={handleStep} className="toolbar-btn btn-step" title="单步执行" disabled={executionMode === 'running'}>
|
||||
<SkipForward size={16} />
|
||||
</button>
|
||||
|
||||
<div className="speed-control">
|
||||
<span className="speed-label">速率:</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="5"
|
||||
step="0.1"
|
||||
value={executionSpeed}
|
||||
onChange={(e) => handleSpeedChange(parseFloat(e.target.value))}
|
||||
className="speed-slider"
|
||||
title={`执行速率: ${executionSpeed.toFixed(1)}x`}
|
||||
/>
|
||||
<span className="speed-value">{executionSpeed.toFixed(1)}x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-divider" />
|
||||
|
||||
{/* 视图控制 */}
|
||||
<div className="toolbar-section">
|
||||
<button onClick={resetView} className="toolbar-btn" title="重置视图">
|
||||
<Home size={16} />
|
||||
</button>
|
||||
<button onClick={toggleFullscreen} className="toolbar-btn" title={isFullscreen ? '退出全屏' : '全屏编辑'}>
|
||||
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
</button>
|
||||
<button onClick={() => setIsBlackboardOpen(!isBlackboardOpen)} className="toolbar-btn" title="黑板">
|
||||
<Clipboard size={16} />
|
||||
</button>
|
||||
<button onClick={handleCopyBehaviorTree} className="toolbar-btn" title="复制整个行为树结构">
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 文件名 */}
|
||||
<div className="toolbar-section file-info">
|
||||
<span className="file-name">
|
||||
{currentFilePath
|
||||
? `${currentFilePath.split(/[\\/]/).pop()?.replace('.btree', '')}${hasUnsavedChanges ? ' *' : ''}`
|
||||
: t('behaviorTree.title')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="behavior-tree-editor-content">
|
||||
<div className="editor-canvas-area">
|
||||
<BehaviorTreeEditor
|
||||
onNodeSelect={(node) => {
|
||||
messageHub?.publish('behavior-tree:node-selected', { node });
|
||||
}}
|
||||
onNodeCreate={(_template, _position) => {
|
||||
// Node created
|
||||
}}
|
||||
blackboardVariables={blackboardVariables}
|
||||
projectPath={projectPath || propProjectPath || null}
|
||||
showToolbar={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isBlackboardOpen && (
|
||||
<div className="blackboard-sidebar">
|
||||
<div className="blackboard-header">
|
||||
<span>{t('behaviorTree.blackboard')}</span>
|
||||
<button onClick={() => setIsBlackboardOpen(false)} className="close-btn">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="blackboard-content">
|
||||
<BehaviorTreeBlackboard
|
||||
variables={blackboardVariables}
|
||||
initialVariables={isExecuting ? initialBlackboardVariables : undefined}
|
||||
globalVariables={globalVariables}
|
||||
onVariableChange={handleVariableChange}
|
||||
onVariableAdd={handleVariableAdd}
|
||||
onVariableDelete={handleVariableDelete}
|
||||
onVariableRename={handleVariableRename}
|
||||
onGlobalVariableChange={handleGlobalVariableChange}
|
||||
onGlobalVariableAdd={handleGlobalVariableAdd}
|
||||
onGlobalVariableDelete={handleGlobalVariableDelete}
|
||||
projectPath={projectPath}
|
||||
hasUnsavedGlobalChanges={hasUnsavedGlobalChanges}
|
||||
onSaveGlobal={saveGlobalBlackboard}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isBlackboardOpen && (
|
||||
<button onClick={() => setIsBlackboardOpen(true)} className="blackboard-toggle-btn" title="显示黑板">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExportRuntimeDialog
|
||||
isOpen={isExportDialogOpen}
|
||||
onClose={() => setIsExportDialogOpen(false)}
|
||||
onExport={handleDoExport}
|
||||
hasProject={!!projectPath}
|
||||
availableFiles={availableBTreeFiles}
|
||||
currentFileName={currentFilePath ? currentFilePath.split(/[\\/]/).pop()?.replace('.btree', '') : undefined}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
|
||||
<BehaviorTreeNameDialog
|
||||
isOpen={isSaveDialogOpen}
|
||||
onConfirm={handleSaveDialogConfirm}
|
||||
onCancel={() => setIsSaveDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
.behavior-tree-node-palette-panel {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { BehaviorTreeNodePalette } from '../../../../components/BehaviorTreeNodePalette';
|
||||
import './BehaviorTreeNodePalettePanel.css';
|
||||
|
||||
export const BehaviorTreeNodePalettePanel: React.FC = () => {
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
|
||||
return (
|
||||
<div className="behavior-tree-node-palette-panel">
|
||||
<BehaviorTreeNodePalette
|
||||
onNodeSelect={(template) => {
|
||||
messageHub?.publish('behavior-tree:node-palette-selected', { template });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-45
@@ -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;
|
||||
}
|
||||
-381
@@ -1,381 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, Clipboard } from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { BehaviorTreeNodeProperties } from '../../../../components/BehaviorTreeNodeProperties';
|
||||
import { BehaviorTreeBlackboard } from '../../../../components/BehaviorTreeBlackboard';
|
||||
import { GlobalBlackboardService, type BlackboardValueType, type NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { useBehaviorTreeStore, type Connection } from '../../../../stores/behaviorTreeStore';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useToast } from '../../../../components/Toast';
|
||||
import { createLogger } from '@esengine/ecs-framework';
|
||||
import './BehaviorTreePropertiesPanel.css';
|
||||
|
||||
const logger = createLogger('BehaviorTreePropertiesPanel');
|
||||
|
||||
interface BehaviorTreePropertiesPanelProps {
|
||||
projectPath?: string | null;
|
||||
}
|
||||
|
||||
export const BehaviorTreePropertiesPanel: React.FC<BehaviorTreePropertiesPanelProps> = ({ projectPath: propProjectPath }) => {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const messageHub = Core.services.resolve(MessageHub);
|
||||
|
||||
const {
|
||||
nodes,
|
||||
connections,
|
||||
updateNodes,
|
||||
blackboardVariables,
|
||||
setBlackboardVariables,
|
||||
updateBlackboardVariable,
|
||||
initialBlackboardVariables,
|
||||
isExecuting,
|
||||
removeConnections
|
||||
} = useBehaviorTreeStore();
|
||||
|
||||
const [selectedNode, setSelectedNode] = useState<{
|
||||
id: string;
|
||||
template: NodeTemplate;
|
||||
data: Record<string, any>;
|
||||
} | undefined>();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'properties' | 'blackboard'>('blackboard');
|
||||
const [projectPath, setProjectPath] = useState<string>('');
|
||||
const [globalVariables, setGlobalVariables] = useState<Record<string, any>>({});
|
||||
const [hasUnsavedGlobalChanges, setHasUnsavedGlobalChanges] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initProject = async () => {
|
||||
if (propProjectPath) {
|
||||
setProjectPath(propProjectPath);
|
||||
localStorage.setItem('ecs-project-path', propProjectPath);
|
||||
await loadGlobalBlackboard(propProjectPath);
|
||||
} else {
|
||||
const savedPath = localStorage.getItem('ecs-project-path');
|
||||
if (savedPath) {
|
||||
setProjectPath(savedPath);
|
||||
await loadGlobalBlackboard(savedPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
initProject();
|
||||
}, [propProjectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateGlobalVariables = () => {
|
||||
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||
const allVars = globalBlackboard.getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
};
|
||||
|
||||
updateGlobalVariables();
|
||||
const interval = setInterval(updateGlobalVariables, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleNodeSelected = (data: any) => {
|
||||
if (data.node) {
|
||||
let template = data.node.template;
|
||||
let nodeData = data.node.data;
|
||||
|
||||
if (data.node.data.nodeType === 'blackboard-variable') {
|
||||
const varName = (data.node.data.variableName as string) || '';
|
||||
const varValue = blackboardVariables[varName];
|
||||
const varType = typeof varValue === 'number' ? 'number' :
|
||||
typeof varValue === 'boolean' ? 'boolean' : 'string';
|
||||
|
||||
nodeData = {
|
||||
...data.node.data,
|
||||
__blackboardValue: varValue
|
||||
};
|
||||
|
||||
template = {
|
||||
...data.node.template,
|
||||
properties: [
|
||||
{
|
||||
name: 'variableName',
|
||||
label: t('behaviorTree.variableName'),
|
||||
type: 'variable',
|
||||
defaultValue: varName,
|
||||
description: t('behaviorTree.variableName'),
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: '__blackboardValue',
|
||||
label: t('behaviorTree.currentValue'),
|
||||
type: varType,
|
||||
defaultValue: varValue,
|
||||
description: t('behaviorTree.currentValue')
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
setSelectedNode({
|
||||
id: data.node.id,
|
||||
template,
|
||||
data: nodeData
|
||||
});
|
||||
setActiveTab('properties');
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = messageHub?.subscribe('behavior-tree:node-selected', handleNodeSelected);
|
||||
return () => unsubscribe?.();
|
||||
}, [messageHub, blackboardVariables, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNode && selectedNode.id) {
|
||||
const nodeStillExists = nodes.some((node: any) => node.id === selectedNode.id);
|
||||
if (!nodeStillExists) {
|
||||
setSelectedNode(undefined);
|
||||
}
|
||||
}
|
||||
}, [nodes, selectedNode]);
|
||||
|
||||
const loadGlobalBlackboard = async (path: string) => {
|
||||
try {
|
||||
const json = await invoke<string>('read_global_blackboard', { projectPath: path });
|
||||
const config = JSON.parse(json);
|
||||
Core.services.resolve(GlobalBlackboardService).importConfig(config);
|
||||
|
||||
const allVars = Core.services.resolve(GlobalBlackboardService).getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
setHasUnsavedGlobalChanges(false);
|
||||
logger.info('全局黑板配置已加载');
|
||||
} catch (error) {
|
||||
logger.error('加载全局黑板配置失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveGlobalBlackboard = async () => {
|
||||
if (!projectPath) {
|
||||
logger.error('未设置项目路径,无法保存全局黑板配置');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||
const json = globalBlackboard.toJSON();
|
||||
await invoke('write_global_blackboard', { projectPath, content: json });
|
||||
setHasUnsavedGlobalChanges(false);
|
||||
logger.info('全局黑板配置已保存到', `${projectPath}/.ecs/global-blackboard.json`);
|
||||
showToast('全局黑板已保存', 'success');
|
||||
} catch (error) {
|
||||
logger.error('保存全局黑板配置失败', error);
|
||||
showToast('保存全局黑板失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleVariableChange = (key: string, value: any) => {
|
||||
updateBlackboardVariable(key, value);
|
||||
};
|
||||
|
||||
const handleVariableAdd = (key: string, value: any) => {
|
||||
updateBlackboardVariable(key, value);
|
||||
};
|
||||
|
||||
const handleVariableDelete = (key: string) => {
|
||||
const newVars = { ...blackboardVariables };
|
||||
delete newVars[key];
|
||||
setBlackboardVariables(newVars);
|
||||
};
|
||||
|
||||
const handleVariableRename = (oldKey: string, newKey: string) => {
|
||||
if (oldKey === newKey) return;
|
||||
const newVars = { ...blackboardVariables };
|
||||
const value = newVars[oldKey];
|
||||
delete newVars[oldKey];
|
||||
newVars[newKey] = value;
|
||||
setBlackboardVariables(newVars);
|
||||
};
|
||||
|
||||
const handleGlobalVariableChange = (key: string, value: any) => {
|
||||
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||
globalBlackboard.setValue(key, value, true);
|
||||
const allVars = globalBlackboard.getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
setHasUnsavedGlobalChanges(true);
|
||||
};
|
||||
|
||||
const handleGlobalVariableAdd = (key: string, value: any, type: BlackboardValueType) => {
|
||||
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||
globalBlackboard.defineVariable(key, type, value);
|
||||
const allVars = globalBlackboard.getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
setHasUnsavedGlobalChanges(true);
|
||||
};
|
||||
|
||||
const handleGlobalVariableDelete = (key: string) => {
|
||||
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||
globalBlackboard.removeVariable(key);
|
||||
const allVars = globalBlackboard.getAllVariables();
|
||||
const varsObject: Record<string, any> = {};
|
||||
allVars.forEach((v) => {
|
||||
varsObject[v.name] = v.value;
|
||||
});
|
||||
setGlobalVariables(varsObject);
|
||||
setHasUnsavedGlobalChanges(true);
|
||||
};
|
||||
|
||||
const handlePropertyChange = (propertyName: string, value: any) => {
|
||||
if (!selectedNode) return;
|
||||
|
||||
if (selectedNode.data.nodeType === 'blackboard-variable' && propertyName === '__blackboardValue') {
|
||||
const varName = selectedNode.data.variableName;
|
||||
if (varName) {
|
||||
handleVariableChange(varName, value);
|
||||
setSelectedNode({
|
||||
...selectedNode,
|
||||
data: {
|
||||
...selectedNode.data,
|
||||
__blackboardValue: value
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedNode.data.nodeType === 'blackboard-variable' && propertyName === 'variableName') {
|
||||
const newVarValue = blackboardVariables[value];
|
||||
const newVarType = typeof newVarValue === 'number' ? 'number' :
|
||||
typeof newVarValue === 'boolean' ? 'boolean' : 'string';
|
||||
|
||||
updateNodes((nodes: any) => nodes.map((node: any) => {
|
||||
if (node.id === selectedNode.id) {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
[propertyName]: value
|
||||
},
|
||||
template: {
|
||||
...node.template,
|
||||
displayName: value
|
||||
}
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}));
|
||||
|
||||
const updatedTemplate = {
|
||||
...selectedNode.template,
|
||||
displayName: value,
|
||||
properties: [
|
||||
{
|
||||
name: 'variableName',
|
||||
label: t('behaviorTree.variableName'),
|
||||
type: 'variable' as const,
|
||||
defaultValue: value,
|
||||
description: t('behaviorTree.variableName'),
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: '__blackboardValue',
|
||||
label: t('behaviorTree.currentValue'),
|
||||
type: newVarType as 'string' | 'number' | 'boolean',
|
||||
defaultValue: newVarValue,
|
||||
description: t('behaviorTree.currentValue')
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
setSelectedNode({
|
||||
...selectedNode,
|
||||
template: updatedTemplate,
|
||||
data: {
|
||||
...selectedNode.data,
|
||||
[propertyName]: value,
|
||||
__blackboardValue: newVarValue
|
||||
}
|
||||
});
|
||||
} else {
|
||||
updateNodes((nodes: any) => nodes.map((node: any) => {
|
||||
if (node.id === selectedNode.id) {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
[propertyName]: value
|
||||
}
|
||||
};
|
||||
}
|
||||
return node;
|
||||
}));
|
||||
|
||||
setSelectedNode({
|
||||
...selectedNode,
|
||||
data: {
|
||||
...selectedNode.data,
|
||||
[propertyName]: value
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="behavior-tree-properties-panel">
|
||||
<div className="properties-panel-tabs">
|
||||
<button
|
||||
onClick={() => setActiveTab('properties')}
|
||||
className={`tab-button ${activeTab === 'properties' ? 'active' : ''}`}
|
||||
>
|
||||
<Settings size={16} />
|
||||
{t('behaviorTree.properties')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('blackboard')}
|
||||
className={`tab-button ${activeTab === 'blackboard' ? 'active' : ''}`}
|
||||
>
|
||||
<Clipboard size={16} />
|
||||
{t('behaviorTree.blackboard')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="properties-panel-content">
|
||||
{activeTab === 'properties' ? (
|
||||
<BehaviorTreeNodeProperties
|
||||
selectedNode={selectedNode}
|
||||
projectPath={projectPath}
|
||||
onPropertyChange={handlePropertyChange}
|
||||
/>
|
||||
) : (
|
||||
<BehaviorTreeBlackboard
|
||||
variables={blackboardVariables}
|
||||
initialVariables={isExecuting ? initialBlackboardVariables : undefined}
|
||||
globalVariables={globalVariables}
|
||||
onVariableChange={handleVariableChange}
|
||||
onVariableAdd={handleVariableAdd}
|
||||
onVariableDelete={handleVariableDelete}
|
||||
onVariableRename={handleVariableRename}
|
||||
onGlobalVariableChange={handleGlobalVariableChange}
|
||||
onGlobalVariableAdd={handleGlobalVariableAdd}
|
||||
onGlobalVariableDelete={handleGlobalVariableDelete}
|
||||
projectPath={projectPath}
|
||||
hasUnsavedGlobalChanges={hasUnsavedGlobalChanges}
|
||||
onSaveGlobal={saveGlobalBlackboard}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { BehaviorTreeEditorPanel } from './BehaviorTreeEditorPanel';
|
||||
export { BehaviorTreeNodePalettePanel } from './BehaviorTreeNodePalettePanel';
|
||||
@@ -1,56 +0,0 @@
|
||||
import { NodeTemplate, NodeType } from '@esengine/behavior-tree';
|
||||
import {
|
||||
List, GitBranch, Layers, Shuffle, RotateCcw,
|
||||
Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer,
|
||||
Clock, FileText, Edit, Calculator, Code,
|
||||
Equal, Dices, Settings,
|
||||
Database, TreePine,
|
||||
LucideIcon
|
||||
} from 'lucide-react';
|
||||
|
||||
export const ICON_MAP: Record<string, LucideIcon> = {
|
||||
List,
|
||||
GitBranch,
|
||||
Layers,
|
||||
Shuffle,
|
||||
RotateCcw,
|
||||
Repeat,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
CheckCheck,
|
||||
HelpCircle,
|
||||
Snowflake,
|
||||
Timer,
|
||||
Clock,
|
||||
FileText,
|
||||
Edit,
|
||||
Calculator,
|
||||
Code,
|
||||
Equal,
|
||||
Dices,
|
||||
Settings,
|
||||
Database,
|
||||
TreePine
|
||||
};
|
||||
|
||||
export const ROOT_NODE_TEMPLATE: NodeTemplate = {
|
||||
type: NodeType.Composite,
|
||||
displayName: '根节点',
|
||||
category: '根节点',
|
||||
icon: 'TreePine',
|
||||
description: '行为树根节点',
|
||||
color: '#FFD700',
|
||||
defaultConfig: {
|
||||
nodeType: 'root'
|
||||
},
|
||||
properties: []
|
||||
};
|
||||
|
||||
export const DEFAULT_EDITOR_CONFIG = {
|
||||
enableSnapping: false,
|
||||
gridSize: 20,
|
||||
minZoom: 0.1,
|
||||
maxZoom: 3,
|
||||
showGrid: true,
|
||||
showMinimap: false
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export { useCommandHistory } from './useCommandHistory';
|
||||
export { useNodeOperations } from './useNodeOperations';
|
||||
export { useConnectionOperations } from './useConnectionOperations';
|
||||
export { useCanvasInteraction } from './useCanvasInteraction';
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useUIStore } from '../../application/state/UIStore';
|
||||
|
||||
/**
|
||||
* 画布交互 Hook
|
||||
* 封装画布的缩放、平移等交互逻辑
|
||||
*/
|
||||
export function useCanvasInteraction() {
|
||||
const {
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
isPanning,
|
||||
panStart,
|
||||
setCanvasOffset,
|
||||
setCanvasScale,
|
||||
setIsPanning,
|
||||
setPanStart,
|
||||
resetView
|
||||
} = useUIStore();
|
||||
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const delta = e.deltaY;
|
||||
const scaleFactor = 1.1;
|
||||
|
||||
if (delta < 0) {
|
||||
setCanvasScale(Math.min(canvasScale * scaleFactor, 3));
|
||||
} else {
|
||||
setCanvasScale(Math.max(canvasScale / scaleFactor, 0.1));
|
||||
}
|
||||
}, [canvasScale, setCanvasScale]);
|
||||
|
||||
const startPanning = useCallback((clientX: number, clientY: number) => {
|
||||
setIsPanning(true);
|
||||
setPanStart({ x: clientX, y: clientY });
|
||||
}, [setIsPanning, setPanStart]);
|
||||
|
||||
const updatePanning = useCallback((clientX: number, clientY: number) => {
|
||||
if (!isPanning) return;
|
||||
|
||||
const dx = clientX - panStart.x;
|
||||
const dy = clientY - panStart.y;
|
||||
|
||||
setCanvasOffset({
|
||||
x: canvasOffset.x + dx,
|
||||
y: canvasOffset.y + dy
|
||||
});
|
||||
|
||||
setPanStart({ x: clientX, y: clientY });
|
||||
}, [isPanning, panStart, canvasOffset, setCanvasOffset, setPanStart]);
|
||||
|
||||
const stopPanning = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
}, [setIsPanning]);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
setCanvasScale(Math.min(canvasScale * 1.2, 3));
|
||||
}, [canvasScale, setCanvasScale]);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setCanvasScale(Math.max(canvasScale / 1.2, 0.1));
|
||||
}, [canvasScale, setCanvasScale]);
|
||||
|
||||
const zoomToFit = useCallback(() => {
|
||||
resetView();
|
||||
}, [resetView]);
|
||||
|
||||
const screenToCanvas = useCallback((screenX: number, screenY: number) => {
|
||||
return {
|
||||
x: (screenX - canvasOffset.x) / canvasScale,
|
||||
y: (screenY - canvasOffset.y) / canvasScale
|
||||
};
|
||||
}, [canvasOffset, canvasScale]);
|
||||
|
||||
const canvasToScreen = useCallback((canvasX: number, canvasY: number) => {
|
||||
return {
|
||||
x: canvasX * canvasScale + canvasOffset.x,
|
||||
y: canvasY * canvasScale + canvasOffset.y
|
||||
};
|
||||
}, [canvasOffset, canvasScale]);
|
||||
|
||||
return useMemo(() => ({
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
isPanning,
|
||||
handleWheel,
|
||||
startPanning,
|
||||
updatePanning,
|
||||
stopPanning,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomToFit,
|
||||
screenToCanvas,
|
||||
canvasToScreen
|
||||
}), [
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
isPanning,
|
||||
handleWheel,
|
||||
startPanning,
|
||||
updatePanning,
|
||||
stopPanning,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomToFit,
|
||||
screenToCanvas,
|
||||
canvasToScreen
|
||||
]);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { RefObject, useEffect, useRef } from 'react';
|
||||
import { BehaviorTreeNode, ROOT_NODE_ID } from '../../stores/behaviorTreeStore';
|
||||
|
||||
interface QuickCreateMenuState {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
searchText: string;
|
||||
selectedIndex: number;
|
||||
mode: 'create' | 'replace';
|
||||
replaceNodeId: string | null;
|
||||
}
|
||||
|
||||
interface UseCanvasMouseEventsParams {
|
||||
canvasRef: RefObject<HTMLDivElement>;
|
||||
canvasOffset: { x: number; y: number };
|
||||
canvasScale: number;
|
||||
connectingFrom: string | null;
|
||||
connectingToPos: { x: number; y: number } | null;
|
||||
isBoxSelecting: boolean;
|
||||
boxSelectStart: { x: number; y: number } | null;
|
||||
boxSelectEnd: { x: number; y: number } | null;
|
||||
nodes: BehaviorTreeNode[];
|
||||
selectedNodeIds: string[];
|
||||
quickCreateMenu: QuickCreateMenuState;
|
||||
setConnectingToPos: (pos: { x: number; y: number } | null) => void;
|
||||
setIsBoxSelecting: (isSelecting: boolean) => void;
|
||||
setBoxSelectStart: (pos: { x: number; y: number } | null) => void;
|
||||
setBoxSelectEnd: (pos: { x: number; y: number } | null) => void;
|
||||
setSelectedNodeIds: (ids: string[]) => void;
|
||||
setSelectedConnection: (connection: { from: string; to: string } | null) => void;
|
||||
setQuickCreateMenu: (menu: QuickCreateMenuState) => void;
|
||||
clearConnecting: () => void;
|
||||
clearBoxSelect: () => void;
|
||||
showToast?: (message: string, type: 'success' | 'error' | 'warning' | 'info', duration?: number) => void;
|
||||
}
|
||||
|
||||
export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
||||
const {
|
||||
canvasRef,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
connectingFrom,
|
||||
connectingToPos,
|
||||
isBoxSelecting,
|
||||
boxSelectStart,
|
||||
boxSelectEnd,
|
||||
nodes,
|
||||
selectedNodeIds,
|
||||
quickCreateMenu,
|
||||
setConnectingToPos,
|
||||
setIsBoxSelecting,
|
||||
setBoxSelectStart,
|
||||
setBoxSelectEnd,
|
||||
setSelectedNodeIds,
|
||||
setSelectedConnection,
|
||||
setQuickCreateMenu,
|
||||
clearConnecting,
|
||||
clearBoxSelect,
|
||||
showToast
|
||||
} = params;
|
||||
|
||||
const isBoxSelectingRef = useRef(isBoxSelecting);
|
||||
const boxSelectStartRef = useRef(boxSelectStart);
|
||||
|
||||
useEffect(() => {
|
||||
isBoxSelectingRef.current = isBoxSelecting;
|
||||
boxSelectStartRef.current = boxSelectStart;
|
||||
}, [isBoxSelecting, boxSelectStart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBoxSelecting) return;
|
||||
|
||||
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||
if (!isBoxSelectingRef.current || !boxSelectStartRef.current) return;
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale;
|
||||
const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale;
|
||||
setBoxSelectEnd({ x: canvasX, y: canvasY });
|
||||
};
|
||||
|
||||
const handleGlobalMouseUp = (e: MouseEvent) => {
|
||||
if (!isBoxSelectingRef.current || !boxSelectStartRef.current || !boxSelectEnd) return;
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
clearBoxSelect();
|
||||
return;
|
||||
}
|
||||
|
||||
const minX = Math.min(boxSelectStartRef.current.x, boxSelectEnd.x);
|
||||
const maxX = Math.max(boxSelectStartRef.current.x, boxSelectEnd.x);
|
||||
const minY = Math.min(boxSelectStartRef.current.y, boxSelectEnd.y);
|
||||
const maxY = Math.max(boxSelectStartRef.current.y, boxSelectEnd.y);
|
||||
|
||||
const selectedInBox = nodes
|
||||
.filter((node: BehaviorTreeNode) => {
|
||||
if (node.id === ROOT_NODE_ID) return false;
|
||||
|
||||
const nodeElement = canvasRef.current?.querySelector(`[data-node-id="${node.id}"]`);
|
||||
if (!nodeElement) {
|
||||
return node.position.x >= minX && node.position.x <= maxX &&
|
||||
node.position.y >= minY && node.position.y <= maxY;
|
||||
}
|
||||
|
||||
const nodeRect = nodeElement.getBoundingClientRect();
|
||||
const canvasRect = canvasRef.current!.getBoundingClientRect();
|
||||
|
||||
const nodeLeft = (nodeRect.left - canvasRect.left - canvasOffset.x) / canvasScale;
|
||||
const nodeRight = (nodeRect.right - canvasRect.left - canvasOffset.x) / canvasScale;
|
||||
const nodeTop = (nodeRect.top - canvasRect.top - canvasOffset.y) / canvasScale;
|
||||
const nodeBottom = (nodeRect.bottom - canvasRect.top - canvasOffset.y) / canvasScale;
|
||||
|
||||
return nodeRight > minX && nodeLeft < maxX && nodeBottom > minY && nodeTop < maxY;
|
||||
})
|
||||
.map((node: BehaviorTreeNode) => node.id);
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const newSet = new Set([...selectedNodeIds, ...selectedInBox]);
|
||||
setSelectedNodeIds(Array.from(newSet));
|
||||
} else {
|
||||
setSelectedNodeIds(selectedInBox);
|
||||
}
|
||||
|
||||
clearBoxSelect();
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleGlobalMouseMove);
|
||||
document.addEventListener('mouseup', handleGlobalMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleGlobalMouseMove);
|
||||
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||
};
|
||||
}, [isBoxSelecting, boxSelectStart, boxSelectEnd, nodes, selectedNodeIds, canvasRef, canvasOffset, canvasScale, setBoxSelectEnd, setSelectedNodeIds, clearBoxSelect]);
|
||||
|
||||
const handleCanvasMouseMove = (e: React.MouseEvent) => {
|
||||
if (connectingFrom && canvasRef.current && !quickCreateMenu.visible) {
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale;
|
||||
const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale;
|
||||
setConnectingToPos({
|
||||
x: canvasX,
|
||||
y: canvasY
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasMouseUp = (e: React.MouseEvent) => {
|
||||
if (quickCreateMenu.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectingFrom && connectingToPos) {
|
||||
const sourceNode = nodes.find(n => n.id === connectingFrom);
|
||||
if (sourceNode && !sourceNode.canAddChild()) {
|
||||
const maxChildren = sourceNode.template.maxChildren ?? Infinity;
|
||||
showToast?.(
|
||||
`节点"${sourceNode.template.displayName}"已达到最大子节点数 ${maxChildren}`,
|
||||
'warning'
|
||||
);
|
||||
clearConnecting();
|
||||
setConnectingToPos(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setQuickCreateMenu({
|
||||
visible: true,
|
||||
position: {
|
||||
x: e.clientX,
|
||||
y: e.clientY
|
||||
},
|
||||
searchText: '',
|
||||
selectedIndex: 0,
|
||||
mode: 'create',
|
||||
replaceNodeId: null
|
||||
});
|
||||
setConnectingToPos(null);
|
||||
return;
|
||||
}
|
||||
|
||||
clearConnecting();
|
||||
};
|
||||
|
||||
const handleCanvasMouseDown = (e: React.MouseEvent) => {
|
||||
if (quickCreateMenu.visible) {
|
||||
setQuickCreateMenu({
|
||||
visible: false,
|
||||
position: { x: 0, y: 0 },
|
||||
searchText: '',
|
||||
selectedIndex: 0,
|
||||
mode: 'create',
|
||||
replaceNodeId: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.button === 0 && !e.altKey) {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale;
|
||||
const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale;
|
||||
|
||||
setIsBoxSelecting(true);
|
||||
setBoxSelectStart({ x: canvasX, y: canvasY });
|
||||
setBoxSelectEnd({ x: canvasX, y: canvasY });
|
||||
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
setSelectedNodeIds([]);
|
||||
setSelectedConnection(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleCanvasMouseMove,
|
||||
handleCanvasMouseUp,
|
||||
handleCanvasMouseDown
|
||||
};
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useRef, useCallback, useMemo, useEffect } from 'react';
|
||||
import { CommandManager } from '../../application/commands/CommandManager';
|
||||
|
||||
/**
|
||||
* 撤销/重做功能 Hook
|
||||
*/
|
||||
export function useCommandHistory() {
|
||||
const commandManagerRef = useRef<CommandManager>(new CommandManager({
|
||||
maxHistorySize: 100,
|
||||
autoMerge: true
|
||||
}));
|
||||
|
||||
const commandManager = commandManagerRef.current;
|
||||
|
||||
const canUndo = useCallback(() => {
|
||||
return commandManager.canUndo();
|
||||
}, [commandManager]);
|
||||
|
||||
const canRedo = useCallback(() => {
|
||||
return commandManager.canRedo();
|
||||
}, [commandManager]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (commandManager.canUndo()) {
|
||||
commandManager.undo();
|
||||
}
|
||||
}, [commandManager]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (commandManager.canRedo()) {
|
||||
commandManager.redo();
|
||||
}
|
||||
}, [commandManager]);
|
||||
|
||||
const getUndoHistory = useCallback(() => {
|
||||
return commandManager.getUndoHistory();
|
||||
}, [commandManager]);
|
||||
|
||||
const getRedoHistory = useCallback(() => {
|
||||
return commandManager.getRedoHistory();
|
||||
}, [commandManager]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
commandManager.clear();
|
||||
}, [commandManager]);
|
||||
|
||||
// 键盘快捷键
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
const isCtrlOrCmd = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
if (isCtrlOrCmd && e.key === 'z') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
redo();
|
||||
} else {
|
||||
undo();
|
||||
}
|
||||
} else if (isCtrlOrCmd && e.key === 'y') {
|
||||
e.preventDefault();
|
||||
redo();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [undo, redo]);
|
||||
|
||||
return useMemo(() => ({
|
||||
commandManager,
|
||||
canUndo: canUndo(),
|
||||
canRedo: canRedo(),
|
||||
undo,
|
||||
redo,
|
||||
getUndoHistory,
|
||||
getRedoHistory,
|
||||
clear
|
||||
}), [commandManager, canUndo, canRedo, undo, redo, getUndoHistory, getRedoHistory, clear]);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { ConnectionType } from '../../domain/models/Connection';
|
||||
import { IValidator } from '../../domain/interfaces/IValidator';
|
||||
import { CommandManager } from '../../application/commands/CommandManager';
|
||||
import { TreeStateAdapter } from '../../application/state/BehaviorTreeDataStore';
|
||||
import { AddConnectionUseCase } from '../../application/use-cases/AddConnectionUseCase';
|
||||
import { RemoveConnectionUseCase } from '../../application/use-cases/RemoveConnectionUseCase';
|
||||
|
||||
/**
|
||||
* 连接操作 Hook
|
||||
*/
|
||||
export function useConnectionOperations(
|
||||
validator: IValidator,
|
||||
commandManager: CommandManager
|
||||
) {
|
||||
const treeState = useMemo(() => new TreeStateAdapter(), []);
|
||||
|
||||
const addConnectionUseCase = useMemo(
|
||||
() => new AddConnectionUseCase(commandManager, treeState, validator),
|
||||
[commandManager, treeState, validator]
|
||||
);
|
||||
|
||||
const removeConnectionUseCase = useMemo(
|
||||
() => new RemoveConnectionUseCase(commandManager, treeState),
|
||||
[commandManager, treeState]
|
||||
);
|
||||
|
||||
const addConnection = useCallback((
|
||||
from: string,
|
||||
to: string,
|
||||
connectionType: ConnectionType = 'node',
|
||||
fromProperty?: string,
|
||||
toProperty?: string
|
||||
) => {
|
||||
try {
|
||||
return addConnectionUseCase.execute(from, to, connectionType, fromProperty, toProperty);
|
||||
} catch (error) {
|
||||
console.error('添加连接失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [addConnectionUseCase]);
|
||||
|
||||
const removeConnection = useCallback((
|
||||
from: string,
|
||||
to: string,
|
||||
fromProperty?: string,
|
||||
toProperty?: string
|
||||
) => {
|
||||
try {
|
||||
removeConnectionUseCase.execute(from, to, fromProperty, toProperty);
|
||||
} catch (error) {
|
||||
console.error('移除连接失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [removeConnectionUseCase]);
|
||||
|
||||
return useMemo(() => ({
|
||||
addConnection,
|
||||
removeConnection
|
||||
}), [addConnection, removeConnection]);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useState, RefObject } from 'react';
|
||||
import { NodeTemplate, NodeType } from '@esengine/behavior-tree';
|
||||
import { Position } from '../../domain/value-objects/Position';
|
||||
import { useNodeOperations } from './useNodeOperations';
|
||||
|
||||
interface DraggedVariableData {
|
||||
variableName: string;
|
||||
}
|
||||
|
||||
interface UseDropHandlerParams {
|
||||
canvasRef: RefObject<HTMLDivElement>;
|
||||
canvasOffset: { x: number; y: number };
|
||||
canvasScale: number;
|
||||
nodeOperations: ReturnType<typeof useNodeOperations>;
|
||||
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
export function useDropHandler(params: UseDropHandlerParams) {
|
||||
const {
|
||||
canvasRef,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
nodeOperations,
|
||||
onNodeCreate
|
||||
} = params;
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
try {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const position = {
|
||||
x: (e.clientX - rect.left - canvasOffset.x) / canvasScale,
|
||||
y: (e.clientY - rect.top - canvasOffset.y) / canvasScale
|
||||
};
|
||||
|
||||
const blackboardVariableData = e.dataTransfer.getData('application/blackboard-variable');
|
||||
if (blackboardVariableData) {
|
||||
const variableData = JSON.parse(blackboardVariableData) as DraggedVariableData;
|
||||
|
||||
const variableTemplate: NodeTemplate = {
|
||||
type: NodeType.Action,
|
||||
displayName: variableData.variableName,
|
||||
category: 'Blackboard Variable',
|
||||
icon: 'Database',
|
||||
description: `Blackboard variable: ${variableData.variableName}`,
|
||||
color: '#9c27b0',
|
||||
defaultConfig: {
|
||||
nodeType: 'blackboard-variable',
|
||||
variableName: variableData.variableName
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
name: 'variableName',
|
||||
label: '变量名',
|
||||
type: 'variable',
|
||||
defaultValue: variableData.variableName,
|
||||
description: '黑板变量的名称',
|
||||
required: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
nodeOperations.createNode(
|
||||
variableTemplate,
|
||||
new Position(position.x, position.y),
|
||||
{
|
||||
nodeType: 'blackboard-variable',
|
||||
variableName: variableData.variableName
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let templateData = e.dataTransfer.getData('application/behavior-tree-node');
|
||||
if (!templateData) {
|
||||
templateData = e.dataTransfer.getData('text/plain');
|
||||
}
|
||||
if (!templateData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = JSON.parse(templateData) as NodeTemplate;
|
||||
|
||||
nodeOperations.createNode(
|
||||
template,
|
||||
new Position(position.x, position.y),
|
||||
template.defaultConfig
|
||||
);
|
||||
|
||||
onNodeCreate?.(template, position);
|
||||
} catch (error) {
|
||||
console.error('Failed to create node:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
if (!isDragging) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
if (e.currentTarget === e.target) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
handleDrop,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDragEnter
|
||||
};
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { ask } from '@tauri-apps/plugin-dialog';
|
||||
import { BehaviorTreeNode } from '../../stores/behaviorTreeStore';
|
||||
import { Node } from '../../domain/models/Node';
|
||||
import { Position } from '../../domain/value-objects/Position';
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
|
||||
interface UseEditorHandlersParams {
|
||||
isDraggingNode: boolean;
|
||||
selectedNodeIds: string[];
|
||||
setSelectedNodeIds: (ids: string[]) => void;
|
||||
setNodes: (nodes: Node[]) => void;
|
||||
setConnections: (connections: any[]) => void;
|
||||
resetView: () => void;
|
||||
triggerForceUpdate: () => void;
|
||||
onNodeSelect?: (node: BehaviorTreeNode) => void;
|
||||
rootNodeId: string;
|
||||
rootNodeTemplate: NodeTemplate;
|
||||
}
|
||||
|
||||
export function useEditorHandlers(params: UseEditorHandlersParams) {
|
||||
const {
|
||||
isDraggingNode,
|
||||
selectedNodeIds,
|
||||
setSelectedNodeIds,
|
||||
setNodes,
|
||||
setConnections,
|
||||
resetView,
|
||||
triggerForceUpdate,
|
||||
onNodeSelect,
|
||||
rootNodeId,
|
||||
rootNodeTemplate
|
||||
} = params;
|
||||
|
||||
const handleNodeClick = (e: React.MouseEvent, node: BehaviorTreeNode) => {
|
||||
if (isDraggingNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (selectedNodeIds.includes(node.id)) {
|
||||
setSelectedNodeIds(selectedNodeIds.filter((id: string) => id !== node.id));
|
||||
} else {
|
||||
setSelectedNodeIds([...selectedNodeIds, node.id]);
|
||||
}
|
||||
} else {
|
||||
setSelectedNodeIds([node.id]);
|
||||
}
|
||||
onNodeSelect?.(node);
|
||||
};
|
||||
|
||||
const handleResetView = () => {
|
||||
resetView();
|
||||
requestAnimationFrame(() => {
|
||||
triggerForceUpdate();
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearCanvas = async () => {
|
||||
const confirmed = await ask('确定要清空画布吗?此操作不可撤销。', {
|
||||
title: '清空画布',
|
||||
kind: 'warning'
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
setNodes([
|
||||
new Node(
|
||||
rootNodeId,
|
||||
rootNodeTemplate,
|
||||
{ nodeType: 'root' },
|
||||
new Position(400, 100),
|
||||
[]
|
||||
)
|
||||
]);
|
||||
setConnections([]);
|
||||
setSelectedNodeIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleNodeClick,
|
||||
handleResetView,
|
||||
handleClearCanvas
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { BehaviorTreeExecutor } from '../../utils/BehaviorTreeExecutor';
|
||||
|
||||
export function useEditorState() {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const stopExecutionRef = useRef<(() => void) | null>(null);
|
||||
const executorRef = useRef<BehaviorTreeExecutor | null>(null);
|
||||
|
||||
const [selectedConnection, setSelectedConnection] = useState<{from: string; to: string} | null>(null);
|
||||
|
||||
return {
|
||||
canvasRef,
|
||||
stopExecutionRef,
|
||||
executorRef,
|
||||
selectedConnection,
|
||||
setSelectedConnection
|
||||
};
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { ExecutionController, ExecutionMode } from '../../application/services/ExecutionController';
|
||||
import { BlackboardManager } from '../../application/services/BlackboardManager';
|
||||
import { BehaviorTreeNode, Connection, useBehaviorTreeStore } from '../../stores/behaviorTreeStore';
|
||||
import { ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
||||
import { BlackboardValue } from '../../domain/models/Blackboard';
|
||||
|
||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||
|
||||
interface UseExecutionControllerParams {
|
||||
rootNodeId: string;
|
||||
projectPath: string | null;
|
||||
blackboardVariables: BlackboardVariables;
|
||||
nodes: BehaviorTreeNode[];
|
||||
connections: Connection[];
|
||||
initialBlackboardVariables: BlackboardVariables;
|
||||
onBlackboardUpdate: (variables: BlackboardVariables) => void;
|
||||
onInitialBlackboardSave: (variables: BlackboardVariables) => void;
|
||||
onExecutingChange: (isExecuting: boolean) => void;
|
||||
onSaveNodesDataSnapshot: () => void;
|
||||
onRestoreNodesData: () => void;
|
||||
}
|
||||
|
||||
export function useExecutionController(params: UseExecutionControllerParams) {
|
||||
const {
|
||||
rootNodeId,
|
||||
projectPath,
|
||||
blackboardVariables,
|
||||
nodes,
|
||||
connections,
|
||||
onBlackboardUpdate,
|
||||
onInitialBlackboardSave,
|
||||
onExecutingChange,
|
||||
onSaveNodesDataSnapshot,
|
||||
onRestoreNodesData
|
||||
} = params;
|
||||
|
||||
const [executionMode, setExecutionMode] = useState<ExecutionMode>('idle');
|
||||
const [executionLogs, setExecutionLogs] = useState<ExecutionLog[]>([]);
|
||||
const [executionSpeed, setExecutionSpeed] = useState<number>(1.0);
|
||||
const [tickCount, setTickCount] = useState(0);
|
||||
|
||||
const controller = useMemo(() => {
|
||||
return new ExecutionController({
|
||||
rootNodeId,
|
||||
projectPath,
|
||||
onLogsUpdate: setExecutionLogs,
|
||||
onBlackboardUpdate,
|
||||
onTickCountUpdate: setTickCount,
|
||||
onExecutionStatusUpdate: (statuses, orders) => {
|
||||
const store = useBehaviorTreeStore.getState();
|
||||
store.updateNodeExecutionStatuses(statuses, orders);
|
||||
}
|
||||
});
|
||||
}, [rootNodeId, projectPath, onBlackboardUpdate]);
|
||||
|
||||
const blackboardManager = useMemo(() => new BlackboardManager(), []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controller.destroy();
|
||||
};
|
||||
}, [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
controller.setConnections(connections);
|
||||
}, [connections, controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (executionMode === 'idle') return;
|
||||
|
||||
const executorVars = controller.getBlackboardVariables();
|
||||
|
||||
Object.entries(blackboardVariables).forEach(([key, value]) => {
|
||||
if (executorVars[key] !== value) {
|
||||
controller.updateBlackboardVariable(key, value);
|
||||
}
|
||||
});
|
||||
}, [blackboardVariables, executionMode, controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (executionMode === 'idle') return;
|
||||
|
||||
controller.updateNodes(nodes);
|
||||
}, [nodes, executionMode, controller]);
|
||||
|
||||
const handlePlay = async () => {
|
||||
try {
|
||||
blackboardManager.setInitialVariables(blackboardVariables);
|
||||
blackboardManager.setCurrentVariables(blackboardVariables);
|
||||
onInitialBlackboardSave(blackboardManager.getInitialVariables());
|
||||
onSaveNodesDataSnapshot();
|
||||
onExecutingChange(true);
|
||||
|
||||
setExecutionMode('running');
|
||||
await controller.play(nodes, blackboardVariables, connections);
|
||||
} catch (error) {
|
||||
console.error('Failed to start execution:', error);
|
||||
setExecutionMode('idle');
|
||||
onExecutingChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
try {
|
||||
await controller.pause();
|
||||
const newMode = controller.getMode();
|
||||
setExecutionMode(newMode);
|
||||
} catch (error) {
|
||||
console.error('Failed to pause/resume execution:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
try {
|
||||
await controller.stop();
|
||||
setExecutionMode('idle');
|
||||
setTickCount(0);
|
||||
|
||||
const restoredVars = blackboardManager.restoreInitialVariables();
|
||||
onBlackboardUpdate(restoredVars);
|
||||
onRestoreNodesData();
|
||||
useBehaviorTreeStore.getState().clearNodeExecutionStatuses();
|
||||
onExecutingChange(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to stop execution:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStep = () => {
|
||||
controller.step();
|
||||
setExecutionMode('step');
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await controller.reset();
|
||||
setExecutionMode('idle');
|
||||
setTickCount(0);
|
||||
} catch (error) {
|
||||
console.error('Failed to reset execution:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpeedChange = (speed: number) => {
|
||||
setExecutionSpeed(speed);
|
||||
controller.setSpeed(speed);
|
||||
};
|
||||
|
||||
return {
|
||||
executionMode,
|
||||
executionLogs,
|
||||
executionSpeed,
|
||||
tickCount,
|
||||
handlePlay,
|
||||
handlePause,
|
||||
handleStop,
|
||||
handleStep,
|
||||
handleReset,
|
||||
handleSpeedChange,
|
||||
setExecutionLogs,
|
||||
controller,
|
||||
blackboardManager
|
||||
};
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Connection, ROOT_NODE_ID } from '../../stores/behaviorTreeStore';
|
||||
import { useNodeOperations } from './useNodeOperations';
|
||||
import { useConnectionOperations } from './useConnectionOperations';
|
||||
|
||||
interface UseKeyboardShortcutsParams {
|
||||
selectedNodeIds: string[];
|
||||
selectedConnection: { from: string; to: string } | null;
|
||||
connections: Connection[];
|
||||
nodeOperations: ReturnType<typeof useNodeOperations>;
|
||||
connectionOperations: ReturnType<typeof useConnectionOperations>;
|
||||
setSelectedNodeIds: (ids: string[]) => void;
|
||||
setSelectedConnection: (connection: { from: string; to: string } | null) => void;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(params: UseKeyboardShortcutsParams) {
|
||||
const {
|
||||
selectedNodeIds,
|
||||
selectedConnection,
|
||||
connections,
|
||||
nodeOperations,
|
||||
connectionOperations,
|
||||
setSelectedNodeIds,
|
||||
setSelectedConnection
|
||||
} = params;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
const isEditingText = activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement instanceof HTMLSelectElement ||
|
||||
(activeElement as HTMLElement)?.isContentEditable;
|
||||
|
||||
if (isEditingText) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedConnection) {
|
||||
const conn = connections.find(
|
||||
(c: Connection) => c.from === selectedConnection.from && c.to === selectedConnection.to
|
||||
);
|
||||
if (conn) {
|
||||
connectionOperations.removeConnection(
|
||||
conn.from,
|
||||
conn.to,
|
||||
conn.fromProperty,
|
||||
conn.toProperty
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedConnection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedNodeIds.length > 0) {
|
||||
const nodesToDelete = selectedNodeIds.filter((id: string) => id !== ROOT_NODE_ID);
|
||||
if (nodesToDelete.length > 0) {
|
||||
nodeOperations.deleteNodes(nodesToDelete);
|
||||
setSelectedNodeIds([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [selectedNodeIds, selectedConnection, nodeOperations, connectionOperations, connections, setSelectedNodeIds, setSelectedConnection]);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useState, RefObject } from 'react';
|
||||
import { BehaviorTreeNode, ROOT_NODE_ID } from '../../stores/behaviorTreeStore';
|
||||
import { Position } from '../../domain/value-objects/Position';
|
||||
import { useNodeOperations } from './useNodeOperations';
|
||||
|
||||
interface UseNodeDragParams {
|
||||
canvasRef: RefObject<HTMLDivElement>;
|
||||
canvasOffset: { x: number; y: number };
|
||||
canvasScale: number;
|
||||
nodes: BehaviorTreeNode[];
|
||||
selectedNodeIds: string[];
|
||||
draggingNodeId: string | null;
|
||||
dragStartPositions: Map<string, { x: number; y: number }>;
|
||||
isDraggingNode: boolean;
|
||||
dragDelta: { dx: number; dy: number };
|
||||
nodeOperations: ReturnType<typeof useNodeOperations>;
|
||||
setSelectedNodeIds: (ids: string[]) => void;
|
||||
startDragging: (nodeId: string, startPositions: Map<string, { x: number; y: number }>) => void;
|
||||
stopDragging: () => void;
|
||||
setIsDraggingNode: (isDragging: boolean) => void;
|
||||
setDragDelta: (delta: { dx: number; dy: number }) => void;
|
||||
setIsBoxSelecting: (isSelecting: boolean) => void;
|
||||
setBoxSelectStart: (pos: { x: number; y: number } | null) => void;
|
||||
setBoxSelectEnd: (pos: { x: number; y: number } | null) => void;
|
||||
sortChildrenByPosition: () => void;
|
||||
}
|
||||
|
||||
export function useNodeDrag(params: UseNodeDragParams) {
|
||||
const {
|
||||
canvasRef,
|
||||
canvasOffset,
|
||||
canvasScale,
|
||||
nodes,
|
||||
selectedNodeIds,
|
||||
draggingNodeId,
|
||||
dragStartPositions,
|
||||
isDraggingNode,
|
||||
dragDelta,
|
||||
nodeOperations,
|
||||
setSelectedNodeIds,
|
||||
startDragging,
|
||||
stopDragging,
|
||||
setIsDraggingNode,
|
||||
setDragDelta,
|
||||
setIsBoxSelecting,
|
||||
setBoxSelectStart,
|
||||
setBoxSelectEnd,
|
||||
sortChildrenByPosition
|
||||
} = params;
|
||||
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
const handleNodeMouseDown = (e: React.MouseEvent, nodeId: string) => {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
if (nodeId === ROOT_NODE_ID) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.getAttribute('data-port')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
setIsBoxSelecting(false);
|
||||
setBoxSelectStart(null);
|
||||
setBoxSelectEnd(null);
|
||||
const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId);
|
||||
if (!node) return;
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale;
|
||||
const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale;
|
||||
|
||||
let nodesToDrag: string[];
|
||||
if (selectedNodeIds.includes(nodeId)) {
|
||||
nodesToDrag = selectedNodeIds;
|
||||
} else {
|
||||
nodesToDrag = [nodeId];
|
||||
setSelectedNodeIds([nodeId]);
|
||||
}
|
||||
|
||||
const startPositions = new Map<string, { x: number; y: number }>();
|
||||
nodesToDrag.forEach((id: string) => {
|
||||
const n = nodes.find((node: BehaviorTreeNode) => node.id === id);
|
||||
if (n) {
|
||||
startPositions.set(id, { x: n.position.x, y: n.position.y });
|
||||
}
|
||||
});
|
||||
|
||||
startDragging(nodeId, startPositions);
|
||||
setDragOffset({
|
||||
x: canvasX - node.position.x,
|
||||
y: canvasY - node.position.y
|
||||
});
|
||||
};
|
||||
|
||||
const handleNodeMouseMove = (e: React.MouseEvent) => {
|
||||
if (!draggingNodeId) return;
|
||||
|
||||
if (!isDraggingNode) {
|
||||
setIsDraggingNode(true);
|
||||
}
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale;
|
||||
const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale;
|
||||
|
||||
const newX = canvasX - dragOffset.x;
|
||||
const newY = canvasY - dragOffset.y;
|
||||
|
||||
const draggedNodeStartPos = dragStartPositions.get(draggingNodeId);
|
||||
if (!draggedNodeStartPos) return;
|
||||
|
||||
const deltaX = newX - draggedNodeStartPos.x;
|
||||
const deltaY = newY - draggedNodeStartPos.y;
|
||||
|
||||
setDragDelta({ dx: deltaX, dy: deltaY });
|
||||
};
|
||||
|
||||
const handleNodeMouseUp = () => {
|
||||
if (!draggingNodeId) return;
|
||||
|
||||
if (dragDelta.dx !== 0 || dragDelta.dy !== 0) {
|
||||
const moves: Array<{ nodeId: string; position: Position }> = [];
|
||||
dragStartPositions.forEach((startPos: { x: number; y: number }, nodeId: string) => {
|
||||
moves.push({
|
||||
nodeId,
|
||||
position: new Position(
|
||||
startPos.x + dragDelta.dx,
|
||||
startPos.y + dragDelta.dy
|
||||
)
|
||||
});
|
||||
});
|
||||
nodeOperations.moveNodes(moves);
|
||||
|
||||
setTimeout(() => {
|
||||
sortChildrenByPosition();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
setDragDelta({ dx: 0, dy: 0 });
|
||||
|
||||
stopDragging();
|
||||
|
||||
setTimeout(() => {
|
||||
setIsDraggingNode(false);
|
||||
}, 10);
|
||||
};
|
||||
|
||||
return {
|
||||
handleNodeMouseDown,
|
||||
handleNodeMouseMove,
|
||||
handleNodeMouseUp,
|
||||
dragOffset
|
||||
};
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { NodeTemplate } from '@esengine/behavior-tree';
|
||||
import { Position } from '../../domain/value-objects/Position';
|
||||
import { INodeFactory } from '../../domain/interfaces/INodeFactory';
|
||||
import { IValidator } from '../../domain/interfaces/IValidator';
|
||||
import { CommandManager } from '../../application/commands/CommandManager';
|
||||
import { TreeStateAdapter } from '../../application/state/BehaviorTreeDataStore';
|
||||
import { CreateNodeUseCase } from '../../application/use-cases/CreateNodeUseCase';
|
||||
import { DeleteNodeUseCase } from '../../application/use-cases/DeleteNodeUseCase';
|
||||
import { MoveNodeUseCase } from '../../application/use-cases/MoveNodeUseCase';
|
||||
import { UpdateNodeDataUseCase } from '../../application/use-cases/UpdateNodeDataUseCase';
|
||||
|
||||
/**
|
||||
* 节点操作 Hook
|
||||
*/
|
||||
export function useNodeOperations(
|
||||
nodeFactory: INodeFactory,
|
||||
validator: IValidator,
|
||||
commandManager: CommandManager
|
||||
) {
|
||||
const treeState = useMemo(() => new TreeStateAdapter(), []);
|
||||
|
||||
const createNodeUseCase = useMemo(
|
||||
() => new CreateNodeUseCase(nodeFactory, commandManager, treeState),
|
||||
[nodeFactory, commandManager, treeState]
|
||||
);
|
||||
|
||||
const deleteNodeUseCase = useMemo(
|
||||
() => new DeleteNodeUseCase(commandManager, treeState),
|
||||
[commandManager, treeState]
|
||||
);
|
||||
|
||||
const moveNodeUseCase = useMemo(
|
||||
() => new MoveNodeUseCase(commandManager, treeState),
|
||||
[commandManager, treeState]
|
||||
);
|
||||
|
||||
const updateNodeDataUseCase = useMemo(
|
||||
() => new UpdateNodeDataUseCase(commandManager, treeState),
|
||||
[commandManager, treeState]
|
||||
);
|
||||
|
||||
const createNode = useCallback((
|
||||
template: NodeTemplate,
|
||||
position: Position,
|
||||
data?: Record<string, unknown>
|
||||
) => {
|
||||
return createNodeUseCase.execute(template, position, data);
|
||||
}, [createNodeUseCase]);
|
||||
|
||||
const createNodeByType = useCallback((
|
||||
nodeType: string,
|
||||
position: Position,
|
||||
data?: Record<string, unknown>
|
||||
) => {
|
||||
return createNodeUseCase.executeByType(nodeType, position, data);
|
||||
}, [createNodeUseCase]);
|
||||
|
||||
const deleteNode = useCallback((nodeId: string) => {
|
||||
deleteNodeUseCase.execute(nodeId);
|
||||
}, [deleteNodeUseCase]);
|
||||
|
||||
const deleteNodes = useCallback((nodeIds: string[]) => {
|
||||
deleteNodeUseCase.executeBatch(nodeIds);
|
||||
}, [deleteNodeUseCase]);
|
||||
|
||||
const moveNode = useCallback((nodeId: string, position: Position) => {
|
||||
moveNodeUseCase.execute(nodeId, position);
|
||||
}, [moveNodeUseCase]);
|
||||
|
||||
const moveNodes = useCallback((moves: Array<{ nodeId: string; position: Position }>) => {
|
||||
moveNodeUseCase.executeBatch(moves);
|
||||
}, [moveNodeUseCase]);
|
||||
|
||||
const updateNodeData = useCallback((nodeId: string, data: Record<string, unknown>) => {
|
||||
updateNodeDataUseCase.execute(nodeId, data);
|
||||
}, [updateNodeDataUseCase]);
|
||||
|
||||
return useMemo(() => ({
|
||||
createNode,
|
||||
createNodeByType,
|
||||
deleteNode,
|
||||
deleteNodes,
|
||||
moveNode,
|
||||
moveNodes,
|
||||
updateNodeData
|
||||
}), [createNode, createNodeByType, deleteNode, deleteNodes, moveNode, moveNodes, updateNodeData]);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { BehaviorTreeNode } from '../../stores/behaviorTreeStore';
|
||||
import { ExecutionMode } from '../../application/services/ExecutionController';
|
||||
|
||||
interface UseNodeTrackingParams {
|
||||
nodes: BehaviorTreeNode[];
|
||||
executionMode: ExecutionMode;
|
||||
}
|
||||
|
||||
export function useNodeTracking(params: UseNodeTrackingParams) {
|
||||
const { nodes, executionMode } = params;
|
||||
|
||||
const [uncommittedNodeIds, setUncommittedNodeIds] = useState<Set<string>>(new Set());
|
||||
const activeNodeIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (executionMode === 'idle') {
|
||||
setUncommittedNodeIds(new Set());
|
||||
activeNodeIdsRef.current = new Set(nodes.map((n) => n.id));
|
||||
} else if (executionMode === 'running' || executionMode === 'paused') {
|
||||
const currentNodeIds = new Set(nodes.map((n) => n.id));
|
||||
const newNodeIds = new Set<string>();
|
||||
|
||||
currentNodeIds.forEach((id) => {
|
||||
if (!activeNodeIdsRef.current.has(id)) {
|
||||
newNodeIds.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (newNodeIds.size > 0) {
|
||||
setUncommittedNodeIds((prev) => new Set([...prev, ...newNodeIds]));
|
||||
}
|
||||
}
|
||||
}, [nodes, executionMode]);
|
||||
|
||||
return {
|
||||
uncommittedNodeIds
|
||||
};
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { RefObject } from 'react';
|
||||
import { BehaviorTreeNode, Connection, ROOT_NODE_ID } from '../../stores/behaviorTreeStore';
|
||||
import { PropertyDefinition } from '@esengine/behavior-tree';
|
||||
import { useConnectionOperations } from './useConnectionOperations';
|
||||
|
||||
interface UsePortConnectionParams {
|
||||
canvasRef: RefObject<HTMLDivElement>;
|
||||
canvasOffset: { x: number; y: number };
|
||||
canvasScale: number;
|
||||
nodes: BehaviorTreeNode[];
|
||||
connections: Connection[];
|
||||
connectingFrom: string | null;
|
||||
connectingFromProperty: string | null;
|
||||
connectionOperations: ReturnType<typeof useConnectionOperations>;
|
||||
setConnectingFrom: (nodeId: string | null) => void;
|
||||
setConnectingFromProperty: (propertyName: string | null) => void;
|
||||
clearConnecting: () => void;
|
||||
sortChildrenByPosition: () => void;
|
||||
showToast?: (message: string, type: 'success' | 'error' | 'info' | 'warning') => void;
|
||||
}
|
||||
|
||||
export function usePortConnection(params: UsePortConnectionParams) {
|
||||
const {
|
||||
canvasRef,
|
||||
nodes,
|
||||
connections,
|
||||
connectingFrom,
|
||||
connectingFromProperty,
|
||||
connectionOperations,
|
||||
setConnectingFrom,
|
||||
setConnectingFromProperty,
|
||||
clearConnecting,
|
||||
sortChildrenByPosition,
|
||||
showToast
|
||||
} = params;
|
||||
|
||||
const handlePortMouseDown = (e: React.MouseEvent, nodeId: string, propertyName?: string) => {
|
||||
e.stopPropagation();
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const portType = target.getAttribute('data-port-type');
|
||||
|
||||
setConnectingFrom(nodeId);
|
||||
setConnectingFromProperty(propertyName || null);
|
||||
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.setAttribute('data-connecting-from-port-type', portType || '');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePortMouseUp = (e: React.MouseEvent, nodeId: string, propertyName?: string) => {
|
||||
e.stopPropagation();
|
||||
if (!connectingFrom) {
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectingFrom === nodeId) {
|
||||
showToast?.('不能将节点连接到自己', 'warning');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const toPortType = target.getAttribute('data-port-type');
|
||||
const fromPortType = canvasRef.current?.getAttribute('data-connecting-from-port-type');
|
||||
|
||||
let actualFrom = connectingFrom;
|
||||
let actualTo = nodeId;
|
||||
let actualFromProperty = connectingFromProperty;
|
||||
let actualToProperty = propertyName;
|
||||
|
||||
const needReverse =
|
||||
(fromPortType === 'node-input' || fromPortType === 'property-input') &&
|
||||
(toPortType === 'node-output' || toPortType === 'variable-output');
|
||||
|
||||
if (needReverse) {
|
||||
actualFrom = nodeId;
|
||||
actualTo = connectingFrom;
|
||||
actualFromProperty = propertyName || null;
|
||||
actualToProperty = connectingFromProperty ?? undefined;
|
||||
}
|
||||
|
||||
if (actualFromProperty || actualToProperty) {
|
||||
const existingConnection = connections.find(
|
||||
(conn: Connection) =>
|
||||
(conn.from === actualFrom && conn.to === actualTo &&
|
||||
conn.fromProperty === actualFromProperty && conn.toProperty === actualToProperty) ||
|
||||
(conn.from === actualTo && conn.to === actualFrom &&
|
||||
conn.fromProperty === actualToProperty && conn.toProperty === actualFromProperty)
|
||||
);
|
||||
|
||||
if (existingConnection) {
|
||||
showToast?.('该连接已存在', 'warning');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
|
||||
const toNode = nodes.find((n: BehaviorTreeNode) => n.id === actualTo);
|
||||
if (toNode && actualToProperty) {
|
||||
const targetProperty = toNode.template.properties.find(
|
||||
(p: PropertyDefinition) => p.name === actualToProperty
|
||||
);
|
||||
|
||||
if (!targetProperty?.allowMultipleConnections) {
|
||||
const existingPropertyConnection = connections.find(
|
||||
(conn: Connection) =>
|
||||
conn.connectionType === 'property' &&
|
||||
conn.to === actualTo &&
|
||||
conn.toProperty === actualToProperty
|
||||
);
|
||||
|
||||
if (existingPropertyConnection) {
|
||||
showToast?.('该属性已有连接,请先删除现有连接', 'warning');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
connectionOperations.addConnection(
|
||||
actualFrom,
|
||||
actualTo,
|
||||
'property',
|
||||
actualFromProperty || undefined,
|
||||
actualToProperty || undefined
|
||||
);
|
||||
} catch (error) {
|
||||
showToast?.(error instanceof Error ? error.message : '添加连接失败', 'error');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (actualFrom === ROOT_NODE_ID) {
|
||||
const rootNode = nodes.find((n: BehaviorTreeNode) => n.id === ROOT_NODE_ID);
|
||||
if (rootNode && rootNode.children.length > 0) {
|
||||
showToast?.('根节点只能连接一个子节点', 'warning');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const existingConnection = connections.find(
|
||||
(conn: Connection) =>
|
||||
(conn.from === actualFrom && conn.to === actualTo && conn.connectionType === 'node') ||
|
||||
(conn.from === actualTo && conn.to === actualFrom && conn.connectionType === 'node')
|
||||
);
|
||||
|
||||
if (existingConnection) {
|
||||
showToast?.('该连接已存在', 'warning');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
connectionOperations.addConnection(actualFrom, actualTo, 'node');
|
||||
|
||||
setTimeout(() => {
|
||||
sortChildrenByPosition();
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
showToast?.(error instanceof Error ? error.message : '添加连接失败', 'error');
|
||||
clearConnecting();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
clearConnecting();
|
||||
};
|
||||
|
||||
const handleNodeMouseUpForConnection = (e: React.MouseEvent, nodeId: string) => {
|
||||
if (connectingFrom && connectingFrom !== nodeId) {
|
||||
handlePortMouseUp(e, nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handlePortMouseDown,
|
||||
handlePortMouseUp,
|
||||
handleNodeMouseUpForConnection
|
||||
};
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Node } from '../../domain/models/Node';
|
||||
import { Connection } from '../../domain/models/Connection';
|
||||
|
||||
/**
|
||||
* 节点执行状态
|
||||
*/
|
||||
export type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure';
|
||||
|
||||
/**
|
||||
* 执行模式
|
||||
*/
|
||||
export type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
|
||||
|
||||
/**
|
||||
* 执行日志条目
|
||||
*/
|
||||
export interface ExecutionLog {
|
||||
nodeId: string;
|
||||
status: NodeExecutionStatus;
|
||||
timestamp: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文菜单状态
|
||||
*/
|
||||
export interface ContextMenuState {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
nodeId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速创建菜单状态
|
||||
*/
|
||||
export interface QuickCreateMenuState {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 画布坐标
|
||||
*/
|
||||
export interface CanvasPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择区域
|
||||
*/
|
||||
export interface SelectionBox {
|
||||
start: CanvasPoint;
|
||||
end: CanvasPoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点视图数据(用于渲染)
|
||||
*/
|
||||
export interface NodeViewData {
|
||||
node: Node;
|
||||
isSelected: boolean;
|
||||
isDragging: boolean;
|
||||
executionStatus?: NodeExecutionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接视图数据(用于渲染)
|
||||
*/
|
||||
export interface ConnectionViewData {
|
||||
connection: Connection;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑器配置
|
||||
*/
|
||||
export interface EditorConfig {
|
||||
/**
|
||||
* 是否启用网格吸附
|
||||
*/
|
||||
enableSnapping: boolean;
|
||||
|
||||
/**
|
||||
* 网格大小
|
||||
*/
|
||||
gridSize: number;
|
||||
|
||||
/**
|
||||
* 最小缩放
|
||||
*/
|
||||
minZoom: number;
|
||||
|
||||
/**
|
||||
* 最大缩放
|
||||
*/
|
||||
maxZoom: number;
|
||||
|
||||
/**
|
||||
* 是否显示网格
|
||||
*/
|
||||
showGrid: boolean;
|
||||
|
||||
/**
|
||||
* 是否显示小地图
|
||||
*/
|
||||
showMinimap: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认编辑器配置
|
||||
*/
|
||||
export const DEFAULT_EDITOR_CONFIG: EditorConfig = {
|
||||
enableSnapping: true,
|
||||
gridSize: 20,
|
||||
minZoom: 0.1,
|
||||
maxZoom: 3,
|
||||
showGrid: true,
|
||||
showMinimap: false
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure';
|
||||
|
||||
export class DOMCache {
|
||||
private nodeElements: Map<string, Element> = new Map();
|
||||
private connectionElements: Map<string, Element> = new Map();
|
||||
private lastNodeStatus: Map<string, NodeExecutionStatus> = new Map();
|
||||
private statusTimers: Map<string, number> = new Map();
|
||||
|
||||
getNode(nodeId: string): Element | undefined {
|
||||
let element = this.nodeElements.get(nodeId);
|
||||
if (!element) {
|
||||
element = document.querySelector(`[data-node-id="${nodeId}"]`) || undefined;
|
||||
if (element) {
|
||||
this.nodeElements.set(nodeId, element);
|
||||
}
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
getConnection(connectionKey: string): Element | undefined {
|
||||
let element = this.connectionElements.get(connectionKey);
|
||||
if (!element) {
|
||||
element = document.querySelector(`[data-connection-id="${connectionKey}"]`) || undefined;
|
||||
if (element) {
|
||||
this.connectionElements.set(connectionKey, element);
|
||||
}
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
getLastStatus(nodeId: string): NodeExecutionStatus | undefined {
|
||||
return this.lastNodeStatus.get(nodeId);
|
||||
}
|
||||
|
||||
setLastStatus(nodeId: string, status: NodeExecutionStatus): void {
|
||||
this.lastNodeStatus.set(nodeId, status);
|
||||
}
|
||||
|
||||
hasStatusChanged(nodeId: string, newStatus: NodeExecutionStatus): boolean {
|
||||
return this.lastNodeStatus.get(nodeId) !== newStatus;
|
||||
}
|
||||
|
||||
getStatusTimer(nodeId: string): number | undefined {
|
||||
return this.statusTimers.get(nodeId);
|
||||
}
|
||||
|
||||
setStatusTimer(nodeId: string, timerId: number): void {
|
||||
this.statusTimers.set(nodeId, timerId);
|
||||
}
|
||||
|
||||
clearStatusTimer(nodeId: string): void {
|
||||
const timerId = this.statusTimers.get(nodeId);
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
this.statusTimers.delete(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
clearAllStatusTimers(): void {
|
||||
this.statusTimers.forEach((timerId) => clearTimeout(timerId));
|
||||
this.statusTimers.clear();
|
||||
}
|
||||
|
||||
clearNodeCache(): void {
|
||||
this.nodeElements.clear();
|
||||
}
|
||||
|
||||
clearConnectionCache(): void {
|
||||
this.connectionElements.clear();
|
||||
}
|
||||
|
||||
clearStatusCache(): void {
|
||||
this.lastNodeStatus.clear();
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.clearNodeCache();
|
||||
this.clearConnectionCache();
|
||||
this.clearStatusCache();
|
||||
this.clearAllStatusTimers();
|
||||
}
|
||||
|
||||
removeNodeClasses(nodeId: string, ...classes: string[]): void {
|
||||
const element = this.getNode(nodeId);
|
||||
if (element) {
|
||||
element.classList.remove(...classes);
|
||||
}
|
||||
}
|
||||
|
||||
addNodeClasses(nodeId: string, ...classes: string[]): void {
|
||||
const element = this.getNode(nodeId);
|
||||
if (element) {
|
||||
element.classList.add(...classes);
|
||||
}
|
||||
}
|
||||
|
||||
hasNodeClass(nodeId: string, className: string): boolean {
|
||||
const element = this.getNode(nodeId);
|
||||
return element?.classList.contains(className) || false;
|
||||
}
|
||||
|
||||
setConnectionAttribute(connectionKey: string, attribute: string, value: string): void {
|
||||
const element = this.getConnection(connectionKey);
|
||||
if (element) {
|
||||
element.setAttribute(attribute, value);
|
||||
}
|
||||
}
|
||||
|
||||
getConnectionAttribute(connectionKey: string, attribute: string): string | null {
|
||||
const element = this.getConnection(connectionKey);
|
||||
return element?.getAttribute(attribute) || null;
|
||||
}
|
||||
|
||||
forEachNode(callback: (element: Element, nodeId: string) => void): void {
|
||||
this.nodeElements.forEach((element, nodeId) => {
|
||||
callback(element, nodeId);
|
||||
});
|
||||
}
|
||||
|
||||
forEachConnection(callback: (element: Element, connectionKey: string) => void): void {
|
||||
this.connectionElements.forEach((element, connectionKey) => {
|
||||
callback(element, connectionKey);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { RefObject } from 'react';
|
||||
import { BehaviorTreeNode } from '../../stores/behaviorTreeStore';
|
||||
|
||||
export function getPortPosition(
|
||||
canvasRef: RefObject<HTMLDivElement>,
|
||||
canvasOffset: { x: number; y: number },
|
||||
canvasScale: number,
|
||||
nodes: BehaviorTreeNode[],
|
||||
nodeId: string,
|
||||
propertyName?: string,
|
||||
portType: 'input' | 'output' = 'output'
|
||||
): { x: number; y: number } | null {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return null;
|
||||
|
||||
let selector: string;
|
||||
if (propertyName) {
|
||||
selector = `[data-node-id="${nodeId}"][data-property="${propertyName}"]`;
|
||||
} else {
|
||||
const node = nodes.find((n: BehaviorTreeNode) => n.id === nodeId);
|
||||
if (!node) return null;
|
||||
|
||||
if (node.data.nodeType === 'blackboard-variable') {
|
||||
selector = `[data-node-id="${nodeId}"][data-port-type="variable-output"]`;
|
||||
} else {
|
||||
if (portType === 'input') {
|
||||
selector = `[data-node-id="${nodeId}"][data-port-type="node-input"]`;
|
||||
} else {
|
||||
selector = `[data-node-id="${nodeId}"][data-port-type="node-output"]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const portElement = canvas.querySelector(selector) as HTMLElement;
|
||||
if (!portElement) return null;
|
||||
|
||||
const rect = portElement.getBoundingClientRect();
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
|
||||
const x = (rect.left + rect.width / 2 - canvasRect.left - canvasOffset.x) / canvasScale;
|
||||
const y = (rect.top + rect.height / 2 - canvasRect.top - canvasOffset.y) / canvasScale;
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
Reference in New Issue
Block a user