Refactor/clean architecture phase1 (#215)

* refactor(editor): 建立Clean Architecture领域模型层

* refactor(editor): 实现应用层架构 - 命令模式、用例和状态管理

* refactor(editor): 实现展示层核心Hooks

* refactor(editor): 实现基础设施层和展示层组件

* refactor(editor): 迁移画布和连接渲染到 Clean Architecture 组件

* feat(editor): 集成应用层架构和命令模式,实现撤销/重做功能

* refactor(editor): UI组件拆分

* refactor(editor): 提取快速创建菜单逻辑

* refactor(editor): 重构BehaviorTreeEditor,提取组件和Hook

* refactor(editor): 提取端口连接和键盘事件Hook

* refactor(editor): 提取拖放处理Hook

* refactor(editor): 提取画布交互Hook和工具函数

* refactor(editor): 完成核心重构

* fix(editor): 修复节点无法创建和连接

* refactor(behavior-tree,editor): 重构节点子节点约束系统,实现元数据驱动的架构
This commit is contained in:
YHH
2025-11-03 21:22:16 +08:00
committed by GitHub
parent 40cde9c050
commit adfc7e91b3
104 changed files with 8232 additions and 2506 deletions

View File

@@ -0,0 +1,196 @@
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';

View File

@@ -0,0 +1 @@
export { BehaviorTreeCanvas } from './BehaviorTreeCanvas';

View File

@@ -0,0 +1,110 @@
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>
);
};

View File

@@ -0,0 +1,174 @@
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;
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}
/>
{/* 实际显示的线条 */}
<path
d={pathData.path}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
markerEnd="url(#arrowhead)"
/>
{/* 箭头标记 */}
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="10"
refX="9"
refY="3"
orient="auto"
markerUnits="strokeWidth"
>
<polygon
points="0 0, 10 3, 0 6"
fill={strokeColor}
/>
</marker>
</defs>
{/* 选中时显示的中点 */}
{isSelected && (
<circle
cx={pathData.midX}
cy={pathData.midY}
r="5"
fill={strokeColor}
stroke="#1a1a1a"
strokeWidth="2"
/>
)}
</g>
);
};

View File

@@ -0,0 +1,2 @@
export { ConnectionRenderer } from './ConnectionRenderer';
export { ConnectionLayer } from './ConnectionLayer';

View File

@@ -0,0 +1,319 @@
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 } 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;
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,
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'
].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>
{!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
<div
className="bt-node-missing-executor-warning"
style={{
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
cursor: 'help',
pointerEvents: 'auto',
position: 'relative'
}}
onClick={(e) => e.stopPropagation()}
>
<AlertCircle
size={14}
style={{
color: '#f44336',
flexShrink: 0
}}
/>
<div className="bt-node-missing-executor-tooltip">
"{node.template.className}"
</div>
</div>
)}
{isUncommitted && (
<div
className="bt-node-uncommitted-warning"
style={{
marginLeft: !isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) ? '4px' : 'auto',
display: 'flex',
alignItems: 'center',
cursor: 'help',
pointerEvents: 'auto',
position: 'relative'
}}
onClick={(e) => e.stopPropagation()}
>
<AlertTriangle
size={14}
style={{
color: '#ff5722',
flexShrink: 0
}}
/>
<div className="bt-node-uncommitted-tooltip">
</div>
</div>
)}
{!isRoot && !isUncommitted && node.template.type === 'composite' &&
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) &&
!nodes.some((n) =>
connections.some((c) => c.from === node.id && c.to === n.id)
) && (
<div
className="bt-node-empty-warning-container"
style={{
marginLeft: 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>
);
};

View File

@@ -0,0 +1,219 @@
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>
);
};

View File

@@ -0,0 +1 @@
export { BehaviorTreeNodeRenderer } from './BehaviorTreeNodeRenderer';

View File

@@ -0,0 +1,49 @@
import React from 'react';
interface NodeContextMenuProps {
visible: boolean;
position: { x: number; y: number };
nodeId: string | null;
onReplaceNode: () => void;
}
export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
visible,
position,
onReplaceNode
}) => {
if (!visible) return null;
return (
<div
style={{
position: 'fixed',
left: position.x,
top: position.y,
backgroundColor: '#2d2d30',
border: '1px solid #454545',
borderRadius: '4px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
zIndex: 10000,
minWidth: '150px',
padding: '4px 0'
}}
onClick={(e) => e.stopPropagation()}
>
<div
onClick={onReplaceNode}
style={{
padding: '8px 16px',
cursor: 'pointer',
color: '#cccccc',
fontSize: '13px',
transition: 'background-color 0.15s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
</div>
</div>
);
};

View File

@@ -0,0 +1,242 @@
import React, { useRef, useEffect } from 'react';
import { NodeTemplate, NodeTemplates } from '@esengine/behavior-tree';
import { Search, X, LucideIcon } from 'lucide-react';
interface QuickCreateMenuProps {
visible: boolean;
position: { x: number; y: number };
searchText: string;
selectedIndex: number;
mode: 'create' | 'replace';
iconMap: Record<string, LucideIcon>;
onSearchChange: (text: string) => void;
onIndexChange: (index: number) => void;
onNodeSelect: (template: NodeTemplate) => void;
onClose: () => void;
}
export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
visible,
position,
searchText,
selectedIndex,
iconMap,
onSearchChange,
onIndexChange,
onNodeSelect,
onClose
}) => {
const selectedNodeRef = useRef<HTMLDivElement>(null);
const allTemplates = NodeTemplates.getAllTemplates();
const searchTextLower = searchText.toLowerCase();
const filteredTemplates = searchTextLower
? allTemplates.filter((t: NodeTemplate) => {
const className = t.className || '';
return t.displayName.toLowerCase().includes(searchTextLower) ||
t.description.toLowerCase().includes(searchTextLower) ||
t.category.toLowerCase().includes(searchTextLower) ||
className.toLowerCase().includes(searchTextLower);
})
: allTemplates;
useEffect(() => {
if (selectedNodeRef.current) {
selectedNodeRef.current.scrollIntoView({
block: 'nearest',
behavior: 'smooth'
});
}
}, [selectedIndex]);
if (!visible) return null;
return (
<>
<style>{`
.quick-create-menu-list::-webkit-scrollbar {
width: 8px;
}
.quick-create-menu-list::-webkit-scrollbar-track {
background: #1e1e1e;
}
.quick-create-menu-list::-webkit-scrollbar-thumb {
background: #3c3c3c;
border-radius: 4px;
}
.quick-create-menu-list::-webkit-scrollbar-thumb:hover {
background: #4c4c4c;
}
`}</style>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '300px',
maxHeight: '400px',
backgroundColor: '#2d2d2d',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* 搜索框 */}
<div style={{
padding: '12px',
borderBottom: '1px solid #3c3c3c',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<Search size={16} style={{ color: '#999', flexShrink: 0 }} />
<input
type="text"
placeholder="搜索节点..."
autoFocus
value={searchText}
onChange={(e) => {
onSearchChange(e.target.value);
onIndexChange(0);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
onIndexChange(Math.min(selectedIndex + 1, filteredTemplates.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
onIndexChange(Math.max(selectedIndex - 1, 0));
} else if (e.key === 'Enter' && filteredTemplates.length > 0) {
e.preventDefault();
const selectedTemplate = filteredTemplates[selectedIndex];
if (selectedTemplate) {
onNodeSelect(selectedTemplate);
}
}
}}
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: '#ccc',
fontSize: '14px',
padding: '4px'
}}
/>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: '#999',
cursor: 'pointer',
padding: '4px',
display: 'flex',
alignItems: 'center'
}}
>
<X size={16} />
</button>
</div>
{/* 节点列表 */}
<div
className="quick-create-menu-list"
style={{
flex: 1,
overflowY: 'auto',
padding: '8px'
}}
>
{filteredTemplates.length === 0 ? (
<div style={{
padding: '20px',
textAlign: 'center',
color: '#666',
fontSize: '12px'
}}>
</div>
) : (
filteredTemplates.map((template: NodeTemplate, index: number) => {
const IconComponent = template.icon ? iconMap[template.icon] : null;
const className = template.className || '';
const isSelected = index === selectedIndex;
return (
<div
key={index}
ref={isSelected ? selectedNodeRef : null}
onClick={() => onNodeSelect(template)}
onMouseEnter={() => onIndexChange(index)}
style={{
padding: '8px 12px',
marginBottom: '4px',
backgroundColor: isSelected ? '#0e639c' : '#1e1e1e',
borderLeft: `3px solid ${template.color || '#666'}`,
borderRadius: '3px',
cursor: 'pointer',
transition: 'all 0.15s',
transform: isSelected ? 'translateX(2px)' : 'translateX(0)'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '4px'
}}>
{IconComponent && (
<IconComponent size={14} style={{ color: template.color || '#999', flexShrink: 0 }} />
)}
<div style={{ flex: 1 }}>
<div style={{
color: '#ccc',
fontSize: '13px',
fontWeight: '500',
marginBottom: '2px'
}}>
{template.displayName}
</div>
{className && (
<div style={{
color: '#666',
fontSize: '10px',
fontFamily: 'Consolas, Monaco, monospace',
opacity: 0.8
}}>
{className}
</div>
)}
</div>
</div>
<div style={{
fontSize: '11px',
color: '#999',
lineHeight: '1.4',
marginBottom: '2px'
}}>
{template.description}
</div>
<div style={{
fontSize: '10px',
color: '#666'
}}>
{template.category}
</div>
</div>
);
})
)}
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,278 @@
import React from 'react';
import { Play, Pause, Square, SkipForward, RotateCcw, Trash2, Undo, Redo } from 'lucide-react';
type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
interface EditorToolbarProps {
executionMode: ExecutionMode;
canUndo: boolean;
canRedo: boolean;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
onStep: () => void;
onReset: () => void;
onUndo: () => void;
onRedo: () => void;
onResetView: () => void;
onClearCanvas: () => void;
}
export const EditorToolbar: React.FC<EditorToolbarProps> = ({
executionMode,
canUndo,
canRedo,
onPlay,
onPause,
onStop,
onStep,
onReset,
onUndo,
onRedo,
onResetView,
onClearCanvas
}) => {
return (
<div style={{
position: 'absolute',
top: '10px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: '8px',
backgroundColor: 'rgba(45, 45, 45, 0.95)',
padding: '8px',
borderRadius: '6px',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
zIndex: 100
}}>
{/* 播放按钮 */}
<button
onClick={onPlay}
disabled={executionMode === 'running'}
style={{
padding: '8px',
backgroundColor: executionMode === 'running' ? '#2d2d2d' : '#4caf50',
border: 'none',
borderRadius: '4px',
color: executionMode === 'running' ? '#666' : '#fff',
cursor: executionMode === 'running' ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title="运行 (Play)"
>
<Play size={16} />
</button>
{/* 暂停按钮 */}
<button
onClick={onPause}
disabled={executionMode === 'idle'}
style={{
padding: '8px',
backgroundColor: executionMode === 'idle' ? '#2d2d2d' : '#ff9800',
border: 'none',
borderRadius: '4px',
color: executionMode === 'idle' ? '#666' : '#fff',
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title={executionMode === 'paused' ? '继续' : '暂停'}
>
{executionMode === 'paused' ? <Play size={16} /> : <Pause size={16} />}
</button>
{/* 停止按钮 */}
<button
onClick={onStop}
disabled={executionMode === 'idle'}
style={{
padding: '8px',
backgroundColor: executionMode === 'idle' ? '#2d2d2d' : '#f44336',
border: 'none',
borderRadius: '4px',
color: executionMode === 'idle' ? '#666' : '#fff',
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title="停止"
>
<Square size={16} />
</button>
{/* 单步执行按钮 */}
<button
onClick={onStep}
disabled={executionMode !== 'idle' && executionMode !== 'paused'}
style={{
padding: '8px',
backgroundColor: (executionMode !== 'idle' && executionMode !== 'paused') ? '#2d2d2d' : '#2196f3',
border: 'none',
borderRadius: '4px',
color: (executionMode !== 'idle' && executionMode !== 'paused') ? '#666' : '#fff',
cursor: (executionMode !== 'idle' && executionMode !== 'paused') ? 'not-allowed' : 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title="单步执行"
>
<SkipForward size={16} />
</button>
{/* 重置按钮 */}
<button
onClick={onReset}
style={{
padding: '8px',
backgroundColor: '#9e9e9e',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title="重置"
>
<RotateCcw size={16} />
</button>
{/* 分隔符 */}
<div style={{
width: '1px',
backgroundColor: '#666',
margin: '4px 0'
}} />
{/* 重置视图按钮 */}
<button
onClick={onResetView}
style={{
padding: '8px 12px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#cccccc',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
title="重置视图 (滚轮缩放, Alt+拖动平移)"
>
<RotateCcw size={14} />
View
</button>
{/* 清空画布按钮 */}
<button
style={{
padding: '8px 12px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#cccccc',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
title="清空画布"
onClick={onClearCanvas}
>
<Trash2 size={14} />
</button>
{/* 分隔符 */}
<div style={{
width: '1px',
height: '24px',
backgroundColor: '#555',
margin: '0 4px'
}} />
{/* 撤销按钮 */}
<button
onClick={onUndo}
disabled={!canUndo}
style={{
padding: '8px',
backgroundColor: canUndo ? '#3c3c3c' : '#2d2d2d',
border: 'none',
borderRadius: '4px',
color: canUndo ? '#cccccc' : '#666',
cursor: canUndo ? 'pointer' : 'not-allowed',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title="撤销 (Ctrl+Z)"
>
<Undo size={16} />
</button>
{/* 重做按钮 */}
<button
onClick={onRedo}
disabled={!canRedo}
style={{
padding: '8px',
backgroundColor: canRedo ? '#3c3c3c' : '#2d2d2d',
border: 'none',
borderRadius: '4px',
color: canRedo ? '#cccccc' : '#666',
cursor: canRedo ? 'pointer' : 'not-allowed',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title="重做 (Ctrl+Shift+Z / Ctrl+Y)"
>
<Redo size={16} />
</button>
{/* 状态指示器 */}
<div style={{
padding: '8px 12px',
backgroundColor: '#1e1e1e',
borderRadius: '4px',
fontSize: '12px',
color: '#ccc',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}>
<span style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor:
executionMode === 'running' ? '#4caf50' :
executionMode === 'paused' ? '#ff9800' : '#666'
}} />
{executionMode === 'idle' ? 'Idle' :
executionMode === 'running' ? 'Running' :
executionMode === 'paused' ? 'Paused' : 'Step'}
</div>
</div>
);
};

View File

@@ -0,0 +1,56 @@
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
};

View File

@@ -0,0 +1,4 @@
export { useCommandHistory } from './useCommandHistory';
export { useNodeOperations } from './useNodeOperations';
export { useConnectionOperations } from './useConnectionOperations';
export { useCanvasInteraction } from './useCanvasInteraction';

View File

@@ -0,0 +1,110 @@
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
]);
}

View File

@@ -0,0 +1,167 @@
import { RefObject } 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;
}
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
} = params;
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
});
}
if (isBoxSelecting && boxSelectStart) {
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 handleCanvasMouseUp = (e: React.MouseEvent) => {
if (quickCreateMenu.visible) {
return;
}
if (connectingFrom && connectingToPos) {
setQuickCreateMenu({
visible: true,
position: {
x: e.clientX,
y: e.clientY
},
searchText: '',
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
setConnectingToPos(null);
return;
}
clearConnecting();
if (isBoxSelecting && boxSelectStart && boxSelectEnd) {
const minX = Math.min(boxSelectStart.x, boxSelectEnd.x);
const maxX = Math.max(boxSelectStart.x, boxSelectEnd.x);
const minY = Math.min(boxSelectStart.y, boxSelectEnd.y);
const maxY = Math.max(boxSelectStart.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 rect = nodeElement.getBoundingClientRect();
const canvasRect = canvasRef.current!.getBoundingClientRect();
const nodeLeft = (rect.left - canvasRect.left - canvasOffset.x) / canvasScale;
const nodeRight = (rect.right - canvasRect.left - canvasOffset.x) / canvasScale;
const nodeTop = (rect.top - canvasRect.top - canvasOffset.y) / canvasScale;
const nodeBottom = (rect.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();
};
const handleCanvasMouseDown = (e: React.MouseEvent) => {
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
};
}

View File

@@ -0,0 +1,80 @@
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]);
}

View File

@@ -0,0 +1,61 @@
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]);
}

View File

@@ -0,0 +1,129 @@
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
};
}

View File

@@ -0,0 +1,84 @@
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
};
}

View File

@@ -0,0 +1,18 @@
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
};
}

View File

@@ -0,0 +1,148 @@
import { useState, useEffect, useMemo } from 'react';
import { ExecutionController, ExecutionMode } from '../../application/services/ExecutionController';
import { BlackboardManager } from '../../application/services/BlackboardManager';
import { BehaviorTreeNode, Connection } 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;
}
export function useExecutionController(params: UseExecutionControllerParams) {
const {
rootNodeId,
projectPath,
blackboardVariables,
nodes,
connections,
onBlackboardUpdate,
onInitialBlackboardSave,
onExecutingChange
} = 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
});
}, [rootNodeId, projectPath]);
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]);
const handlePlay = async () => {
try {
blackboardManager.setInitialVariables(blackboardVariables);
blackboardManager.setCurrentVariables(blackboardVariables);
onInitialBlackboardSave(blackboardManager.getInitialVariables());
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);
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
};
}

View File

@@ -0,0 +1,72 @@
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]);
}

View File

@@ -0,0 +1,161 @@
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
};
}

View File

@@ -0,0 +1,88 @@
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]);
}

View File

@@ -0,0 +1,39 @@
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
};
}

View File

@@ -0,0 +1,182 @@
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
};
}

View File

@@ -0,0 +1,121 @@
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
};

View File

@@ -0,0 +1,125 @@
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);
});
}
}

View File

@@ -0,0 +1,44 @@
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 };
}