Feature/runtime cdn and plugin loader (#240)

* feat(ui): 完善 UI 布局系统和编辑器可视化工具

* refactor: 移除 ModuleRegistry,统一使用 PluginManager 插件系统

* fix: 修复 CodeQL 警告并提升测试覆盖率

* refactor: 分离运行时入口点,解决 runtime bundle 包含 React 的问题

* fix(ci): 添加 editor-core 和 editor-runtime 到 CI 依赖构建步骤

* docs: 完善 ServiceContainer 文档,新增 Symbol.for 模式和 @InjectProperty 说明

* fix(ci): 修复 type-check 失败问题

* fix(ci): 修复类型检查失败问题

* fix(ci): 修复类型检查失败问题

* fix(ci): behavior-tree 构建添加 @tauri-apps 外部依赖

* fix(ci): behavior-tree 添加 @tauri-apps/plugin-fs 类型依赖

* fix(ci): platform-web 添加缺失的 behavior-tree 依赖

* fix(lint): 移除正则表达式中不必要的转义字符
This commit is contained in:
YHH
2025-11-27 20:42:46 +08:00
committed by GitHub
parent 71869b1a58
commit 107439d70c
367 changed files with 10661 additions and 12473 deletions

View File

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

View File

@@ -1,791 +0,0 @@
import { React, useState, Icons } from '@esengine/editor-runtime';
const { Clipboard, Edit2, Trash2, ChevronDown, ChevronRight, Globe, GripVertical, ChevronLeft, Plus, Copy } = Icons;
type SimpleBlackboardType = 'number' | 'string' | 'boolean' | 'object';
interface BlackboardPanelProps {
variables: Record<string, any>;
initialVariables?: Record<string, any>;
globalVariables?: Record<string, any>;
onVariableChange: (key: string, value: any) => void;
onVariableAdd: (key: string, value: any, type: SimpleBlackboardType) => void;
onVariableDelete: (key: string) => void;
onVariableRename?: (oldKey: string, newKey: string) => void;
onGlobalVariableChange?: (key: string, value: any) => void;
onGlobalVariableAdd?: (key: string, value: any, type: SimpleBlackboardType) => void;
onGlobalVariableDelete?: (key: string) => void;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
}
/**
* 黑板面板组件 - 内嵌在编辑器侧边
* 支持本地变量和全局变量的管理
*/
export const BlackboardPanel: React.FC<BlackboardPanelProps> = ({
variables,
initialVariables,
globalVariables,
onVariableChange,
onVariableAdd,
onVariableDelete,
onVariableRename,
onGlobalVariableChange,
onGlobalVariableAdd,
onGlobalVariableDelete,
isCollapsed = false,
onToggleCollapse
}) => {
const [viewMode, setViewMode] = useState<'local' | 'global'>('local');
const [isAdding, setIsAdding] = useState(false);
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
const [newType, setNewType] = useState<SimpleBlackboardType>('string');
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editingNewKey, setEditingNewKey] = useState('');
const [editValue, setEditValue] = useState('');
const [editType, setEditType] = useState<SimpleBlackboardType>('string');
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const isModified = (key: string): boolean => {
if (!initialVariables || viewMode !== 'local') return false;
return JSON.stringify(variables[key]) !== JSON.stringify(initialVariables[key]);
};
const handleAddVariable = () => {
if (!newKey.trim()) return;
let parsedValue: any = newValue;
if (newType === 'number') {
parsedValue = parseFloat(newValue) || 0;
} else if (newType === 'boolean') {
parsedValue = newValue === 'true';
} else if (newType === 'object') {
try {
parsedValue = JSON.parse(newValue);
} catch {
parsedValue = {};
}
}
if (viewMode === 'global' && onGlobalVariableAdd) {
onGlobalVariableAdd(newKey, parsedValue, newType);
} else {
onVariableAdd(newKey, parsedValue, newType);
}
setNewKey('');
setNewValue('');
setIsAdding(false);
};
const handleStartEdit = (key: string, value: any) => {
setEditingKey(key);
setEditingNewKey(key);
const currentType = getVariableType(value);
setEditType(currentType);
setEditValue(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
};
const handleSaveEdit = (key: string) => {
const newKey = editingNewKey.trim();
if (!newKey) return;
let parsedValue: any = editValue;
if (editType === 'number') {
parsedValue = parseFloat(editValue) || 0;
} else if (editType === 'boolean') {
parsedValue = editValue === 'true' || editValue === '1';
} else if (editType === 'object') {
try {
parsedValue = JSON.parse(editValue);
} catch {
return;
}
}
if (viewMode === 'global' && onGlobalVariableChange) {
if (newKey !== key && onGlobalVariableDelete) {
onGlobalVariableDelete(key);
}
onGlobalVariableChange(newKey, parsedValue);
} else {
if (newKey !== key && onVariableRename) {
onVariableRename(key, newKey);
}
onVariableChange(newKey, parsedValue);
}
setEditingKey(null);
};
const toggleGroup = (groupName: string) => {
setCollapsedGroups((prev) => {
const newSet = new Set(prev);
if (newSet.has(groupName)) {
newSet.delete(groupName);
} else {
newSet.add(groupName);
}
return newSet;
});
};
const getVariableType = (value: any): SimpleBlackboardType => {
if (typeof value === 'number') return 'number';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'object') return 'object';
return 'string';
};
const currentVariables = viewMode === 'global' ? (globalVariables || {}) : variables;
const variableEntries = Object.entries(currentVariables);
const currentOnDelete = viewMode === 'global' ? onGlobalVariableDelete : onVariableDelete;
const groupedVariables: Record<string, Array<{ fullKey: string; varName: string; value: any }>> = variableEntries.reduce((groups, [key, value]) => {
const parts = key.split('.');
const groupName = (parts.length > 1 && parts[0]) ? parts[0] : 'default';
const varName = parts.length > 1 ? parts.slice(1).join('.') : key;
if (!groups[groupName]) {
groups[groupName] = [];
}
groups[groupName].push({ fullKey: key, varName, value });
return groups;
}, {} as Record<string, Array<{ fullKey: string; varName: string; value: any }>>);
const groupNames = Object.keys(groupedVariables).sort((a, b) => {
if (a === 'default') return 1;
if (b === 'default') return -1;
return a.localeCompare(b);
});
// 复制变量到剪贴板
const handleCopyVariable = (key: string, value: any) => {
const text = `${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`;
navigator.clipboard.writeText(text);
};
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#1e1e1e',
color: '#cccccc',
borderLeft: '1px solid #333',
transition: 'width 0.2s ease'
}}>
{/* 标题栏 */}
<div style={{
padding: '10px 12px',
backgroundColor: '#252525',
borderBottom: '1px solid #333',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{
fontSize: '13px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '6px',
color: '#ccc'
}}>
<Clipboard size={14} />
{!isCollapsed && <span>Blackboard</span>}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
{!isCollapsed && (
<div style={{
display: 'flex',
backgroundColor: '#1e1e1e',
borderRadius: '3px',
overflow: 'hidden'
}}>
<button
onClick={() => setViewMode('local')}
style={{
padding: '3px 8px',
backgroundColor: viewMode === 'local' ? '#007acc' : 'transparent',
border: 'none',
color: viewMode === 'local' ? '#fff' : '#888',
cursor: 'pointer',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
gap: '3px',
transition: 'all 0.15s'
}}
onMouseEnter={(e) => {
if (viewMode !== 'local') {
e.currentTarget.style.backgroundColor = '#2a2a2a';
}
}}
onMouseLeave={(e) => {
if (viewMode !== 'local') {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<Clipboard size={11} />
Local
</button>
<button
onClick={() => setViewMode('global')}
style={{
padding: '3px 8px',
backgroundColor: viewMode === 'global' ? '#007acc' : 'transparent',
border: 'none',
color: viewMode === 'global' ? '#fff' : '#888',
cursor: 'pointer',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
gap: '3px',
transition: 'all 0.15s'
}}
onMouseEnter={(e) => {
if (viewMode !== 'global') {
e.currentTarget.style.backgroundColor = '#2a2a2a';
}
}}
onMouseLeave={(e) => {
if (viewMode !== 'global') {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<Globe size={11} />
Global
</button>
</div>
)}
{onToggleCollapse && (
<button
onClick={onToggleCollapse}
style={{
padding: '4px',
backgroundColor: 'transparent',
border: 'none',
color: '#888',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
borderRadius: '2px',
transition: 'all 0.15s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#3c3c3c';
e.currentTarget.style.color = '#ccc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = '#888';
}}
title={isCollapsed ? 'Expand' : 'Collapse'}
>
<ChevronLeft size={14} style={{
transform: isCollapsed ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s'
}} />
</button>
)}
</div>
</div>
{!isCollapsed && (
<>
{/* 工具栏 */}
<div style={{
padding: '8px 12px',
backgroundColor: '#222',
borderBottom: '1px solid #333',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '8px'
}}>
<div style={{
flex: 1,
fontSize: '10px',
color: '#888'
}}>
{viewMode === 'local' ? '当前行为树的本地变量' : '所有行为树共享的全局变量'}
</div>
<button
onClick={() => setIsAdding(true)}
style={{
padding: '4px 8px',
backgroundColor: '#007acc',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'background-color 0.15s'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#005a9e'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#007acc'}
>
<Plus size={12} />
Add
</button>
</div>
{/* 变量列表 */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '10px'
}}>
{variableEntries.length === 0 && !isAdding && (
<div style={{
textAlign: 'center',
color: '#666',
fontSize: '12px',
padding: '20px'
}}>
No variables yet
</div>
)}
{/* 添加新变量表单 */}
{isAdding && (
<div style={{
marginBottom: '10px',
padding: '10px',
backgroundColor: '#2d2d2d',
borderRadius: '4px',
border: '1px solid #3c3c3c'
}}>
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Name</div>
<input
type="text"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
placeholder="variable.name"
style={{
width: '100%',
padding: '6px',
marginBottom: '8px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '3px',
color: '#9cdcfe',
fontSize: '12px',
fontFamily: 'monospace'
}}
autoFocus
/>
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Type</div>
<select
value={newType}
onChange={(e) => setNewType(e.target.value as SimpleBlackboardType)}
style={{
width: '100%',
padding: '6px',
marginBottom: '8px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '3px',
color: '#cccccc',
fontSize: '12px'
}}
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="object">Object (JSON)</option>
</select>
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Value</div>
<textarea
placeholder={
newType === 'object' ? '{"key": "value"}' :
newType === 'boolean' ? 'true or false' :
newType === 'number' ? '0' : 'value'
}
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
style={{
width: '100%',
minHeight: newType === 'object' ? '80px' : '30px',
padding: '6px',
marginBottom: '8px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '3px',
color: '#cccccc',
fontSize: '12px',
fontFamily: 'monospace',
resize: 'vertical'
}}
/>
<div style={{ display: 'flex', gap: '5px' }}>
<button
onClick={handleAddVariable}
style={{
flex: 1,
padding: '6px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px'
}}
>
Create
</button>
<button
onClick={() => {
setIsAdding(false);
setNewKey('');
setNewValue('');
}}
style={{
flex: 1,
padding: '6px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '3px',
color: '#ccc',
cursor: 'pointer',
fontSize: '12px'
}}
>
Cancel
</button>
</div>
</div>
)}
{/* 分组显示变量 */}
{groupNames.map((groupName) => {
const isGroupCollapsed = collapsedGroups.has(groupName);
const groupVars = groupedVariables[groupName];
if (!groupVars) return null;
return (
<div key={groupName} style={{ marginBottom: '8px' }}>
{groupName !== 'default' && (
<div
onClick={() => toggleGroup(groupName)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 6px',
backgroundColor: '#252525',
borderRadius: '3px',
cursor: 'pointer',
marginBottom: '4px',
userSelect: 'none'
}}
>
{isGroupCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
<span style={{
fontSize: '11px',
fontWeight: 'bold',
color: '#888'
}}>
{groupName} ({groupVars.length})
</span>
</div>
)}
{!isGroupCollapsed && groupVars.map(({ fullKey: key, varName, value }) => {
const type = getVariableType(value);
const isEditing = editingKey === key;
const handleDragStart = (e: React.DragEvent) => {
const variableData = {
variableName: key,
variableValue: value,
variableType: type
};
e.dataTransfer.setData('application/blackboard-variable', JSON.stringify(variableData));
e.dataTransfer.effectAllowed = 'copy';
};
const typeColor =
type === 'number' ? '#4ec9b0' :
type === 'boolean' ? '#569cd6' :
type === 'object' ? '#ce9178' : '#d4d4d4';
const displayValue = type === 'object' ?
JSON.stringify(value) :
String(value);
const truncatedValue = displayValue.length > 30 ?
displayValue.substring(0, 30) + '...' :
displayValue;
return (
<div
key={key}
draggable={!isEditing}
onDragStart={handleDragStart}
style={{
marginBottom: '6px',
padding: '6px 8px',
backgroundColor: '#2d2d2d',
borderRadius: '3px',
borderLeft: `3px solid ${typeColor}`,
cursor: isEditing ? 'default' : 'grab'
}}
>
{isEditing ? (
<div>
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Name</div>
<input
type="text"
value={editingNewKey}
onChange={(e) => setEditingNewKey(e.target.value)}
style={{
width: '100%',
padding: '4px',
marginBottom: '4px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '2px',
color: '#9cdcfe',
fontSize: '11px',
fontFamily: 'monospace'
}}
/>
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Type</div>
<select
value={editType}
onChange={(e) => setEditType(e.target.value as SimpleBlackboardType)}
style={{
width: '100%',
padding: '4px',
marginBottom: '4px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '2px',
color: '#cccccc',
fontSize: '10px'
}}
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="object">Object (JSON)</option>
</select>
<div style={{ fontSize: '10px', color: '#666', marginBottom: '4px' }}>Value</div>
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
style={{
width: '100%',
minHeight: editType === 'object' ? '60px' : '24px',
padding: '4px',
backgroundColor: '#1e1e1e',
border: '1px solid #0e639c',
borderRadius: '2px',
color: '#cccccc',
fontSize: '11px',
fontFamily: 'monospace',
resize: 'vertical',
marginBottom: '4px'
}}
/>
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={() => handleSaveEdit(key)}
style={{
flex: 1,
padding: '3px 8px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '2px',
color: '#fff',
cursor: 'pointer',
fontSize: '10px'
}}
>
Save
</button>
<button
onClick={() => setEditingKey(null)}
style={{
flex: 1,
padding: '3px 8px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '2px',
color: '#ccc',
cursor: 'pointer',
fontSize: '10px'
}}
>
Cancel
</button>
</div>
</div>
) : (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '11px',
color: '#9cdcfe',
fontWeight: 'bold',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
<GripVertical size={10} style={{ opacity: 0.3, flexShrink: 0 }} />
{varName}
<span style={{
color: '#666',
fontWeight: 'normal',
fontSize: '10px'
}}>({type})</span>
{isModified(key) && (
<span style={{
fontSize: '9px',
color: '#ff9800',
fontWeight: 'bold'
}}>*</span>
)}
</div>
<div style={{
fontSize: '10px',
color: '#888',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontFamily: 'monospace'
}}>
{truncatedValue}
</div>
</div>
<div style={{ display: 'flex', gap: '2px', flexShrink: 0 }}>
<button
onClick={() => handleCopyVariable(key, value)}
style={{
padding: '4px',
backgroundColor: 'transparent',
border: 'none',
color: '#888',
cursor: 'pointer',
borderRadius: '2px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#3c3c3c';
e.currentTarget.style.color = '#ccc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = '#888';
}}
title="Copy"
>
<Copy size={11} />
</button>
<button
onClick={() => handleStartEdit(key, value)}
style={{
padding: '4px',
backgroundColor: 'transparent',
border: 'none',
color: '#888',
cursor: 'pointer',
borderRadius: '2px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#3c3c3c';
e.currentTarget.style.color = '#ccc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = '#888';
}}
title="Edit"
>
<Edit2 size={11} />
</button>
<button
onClick={() => currentOnDelete && currentOnDelete(key)}
style={{
padding: '4px',
backgroundColor: 'transparent',
border: 'none',
color: '#888',
cursor: 'pointer',
borderRadius: '2px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#5a1a1a';
e.currentTarget.style.color = '#f48771';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = '#888';
}}
title="Delete"
>
<Trash2 size={11} />
</button>
</div>
</div>
)}
</div>
);
})}
</div>
);
})}
</div>
{/* 底部信息栏 */}
<div style={{
padding: '8px 12px',
borderTop: '1px solid #333',
fontSize: '11px',
color: '#666',
backgroundColor: '#252525'
}}>
{viewMode === 'local' ? 'Local' : 'Global'}: {variableEntries.length} variable{variableEntries.length !== 1 ? 's' : ''}
</div>
</>
)}
{/* 滚动条样式 */}
<style>{`
.blackboard-scrollable::-webkit-scrollbar {
width: 8px;
}
.blackboard-scrollable::-webkit-scrollbar-track {
background: #1e1e1e;
}
.blackboard-scrollable::-webkit-scrollbar-thumb {
background: #3c3c3c;
border-radius: 4px;
}
.blackboard-scrollable::-webkit-scrollbar-thumb:hover {
background: #4c4c4c;
}
`}</style>
</div>
);
};

View File

@@ -1,216 +0,0 @@
import { React, useRef, useCallback, forwardRef, useState, useEffect } from '@esengine/editor-runtime';
import { useCanvasInteraction } from '../../hooks/useCanvasInteraction';
import { EditorConfig } from '../../types';
import { GridBackground } from './GridBackground';
/**
* 画布组件属性
*/
interface BehaviorTreeCanvasProps {
/**
* 编辑器配置
*/
config: EditorConfig;
/**
* 子组件
*/
children: React.ReactNode;
/**
* 画布点击事件
*/
onClick?: (e: React.MouseEvent) => void;
/**
* 画布双击事件
*/
onDoubleClick?: (e: React.MouseEvent) => void;
/**
* 画布右键事件
*/
onContextMenu?: (e: React.MouseEvent) => void;
/**
* 鼠标移动事件
*/
onMouseMove?: (e: React.MouseEvent) => void;
/**
* 鼠标按下事件
*/
onMouseDown?: (e: React.MouseEvent) => void;
/**
* 鼠标抬起事件
*/
onMouseUp?: (e: React.MouseEvent) => void;
/**
* 鼠标离开事件
*/
onMouseLeave?: (e: React.MouseEvent) => void;
/**
* 拖放事件
*/
onDrop?: (e: React.DragEvent) => void;
/**
* 拖动悬停事件
*/
onDragOver?: (e: React.DragEvent) => void;
/**
* 拖动进入事件
*/
onDragEnter?: (e: React.DragEvent) => void;
/**
* 拖动离开事件
*/
onDragLeave?: (e: React.DragEvent) => void;
}
/**
* 行为树画布组件
* 负责画布的渲染、缩放、平移等基础功能
*/
export const BehaviorTreeCanvas = forwardRef<HTMLDivElement, BehaviorTreeCanvasProps>(({
config,
children,
onClick,
onDoubleClick,
onContextMenu,
onMouseMove,
onMouseDown,
onMouseUp,
onMouseLeave,
onDrop,
onDragOver,
onDragEnter,
onDragLeave
}, forwardedRef) => {
const internalRef = useRef<HTMLDivElement>(null);
const canvasRef = (forwardedRef as React.RefObject<HTMLDivElement>) || internalRef;
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const {
canvasOffset,
canvasScale,
isPanning,
handleWheel,
startPanning,
updatePanning,
stopPanning
} = useCanvasInteraction();
// 监听画布尺寸变化
useEffect(() => {
const updateSize = () => {
if (canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
setCanvasSize({
width: rect.width,
height: rect.height
});
}
};
updateSize();
const resizeObserver = new ResizeObserver(updateSize);
if (canvasRef.current) {
resizeObserver.observe(canvasRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [canvasRef]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button === 1 || (e.button === 0 && e.altKey)) {
e.preventDefault();
startPanning(e.clientX, e.clientY);
}
onMouseDown?.(e);
}, [startPanning, onMouseDown]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (isPanning) {
updatePanning(e.clientX, e.clientY);
}
onMouseMove?.(e);
}, [isPanning, updatePanning, onMouseMove]);
const handleMouseUp = useCallback((e: React.MouseEvent) => {
if (isPanning) {
stopPanning();
}
onMouseUp?.(e);
}, [isPanning, stopPanning, onMouseUp]);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
onContextMenu?.(e);
}, [onContextMenu]);
return (
<div
ref={canvasRef}
className="behavior-tree-canvas"
style={{
position: 'relative',
width: '100%',
height: '100%',
overflow: 'hidden',
cursor: isPanning ? 'grabbing' : 'default',
backgroundColor: '#1a1a1a'
}}
onWheel={handleWheel}
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={handleContextMenu}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={onMouseLeave}
onDrop={onDrop}
onDragOver={onDragOver}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
>
{/* 网格背景 */}
{config.showGrid && canvasSize.width > 0 && canvasSize.height > 0 && (
<GridBackground
canvasOffset={canvasOffset}
canvasScale={canvasScale}
width={canvasSize.width}
height={canvasSize.height}
/>
)}
{/* 内容容器(应用变换) */}
<div
className="canvas-content"
style={{
position: 'absolute',
transformOrigin: '0 0',
transform: `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasScale})`,
width: '100%',
height: '100%'
}}
>
{children}
</div>
</div>
);
});
BehaviorTreeCanvas.displayName = 'BehaviorTreeCanvas';

View File

@@ -1,127 +0,0 @@
import { React, useMemo } from '@esengine/editor-runtime';
interface GridBackgroundProps {
canvasOffset: { x: number; y: number };
canvasScale: number;
width: number;
height: number;
}
/**
* 编辑器网格背景
*/
export const GridBackground: React.FC<GridBackgroundProps> = ({
canvasOffset,
canvasScale,
width,
height
}) => {
const gridPattern = useMemo(() => {
// 基础网格大小(未缩放)
const baseGridSize = 20;
const baseDotSize = 1.5;
// 根据缩放级别调整网格大小
const gridSize = baseGridSize * canvasScale;
const dotSize = Math.max(baseDotSize, baseDotSize * canvasScale);
// 计算网格偏移(考虑画布偏移)
const offsetX = canvasOffset.x % gridSize;
const offsetY = canvasOffset.y % gridSize;
// 计算需要渲染的网格点数量
const cols = Math.ceil(width / gridSize) + 2;
const rows = Math.ceil(height / gridSize) + 2;
const dots: Array<{ x: number; y: number }> = [];
for (let i = -1; i < rows; i++) {
for (let j = -1; j < cols; j++) {
dots.push({
x: j * gridSize + offsetX,
y: i * gridSize + offsetY
});
}
}
return { dots, dotSize, gridSize };
}, [canvasOffset, canvasScale, width, height]);
// 大网格每5个小格一个大格
const majorGridPattern = useMemo(() => {
const majorGridSize = gridPattern.gridSize * 5;
const offsetX = canvasOffset.x % majorGridSize;
const offsetY = canvasOffset.y % majorGridSize;
const lines: Array<{ type: 'h' | 'v'; pos: number }> = [];
// 垂直线
const vCols = Math.ceil(width / majorGridSize) + 2;
for (let i = -1; i < vCols; i++) {
lines.push({
type: 'v',
pos: i * majorGridSize + offsetX
});
}
// 水平线
const hRows = Math.ceil(height / majorGridSize) + 2;
for (let i = -1; i < hRows; i++) {
lines.push({
type: 'h',
pos: i * majorGridSize + offsetY
});
}
return lines;
}, [canvasOffset, canvasScale, width, height, gridPattern.gridSize]);
return (
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none'
}}
>
{/* 主网格线 */}
{majorGridPattern.map((line, idx) => (
line.type === 'v' ? (
<line
key={`v-${idx}`}
x1={line.pos}
y1={0}
x2={line.pos}
y2={height}
stroke="rgba(255, 255, 255, 0.03)"
strokeWidth="1"
/>
) : (
<line
key={`h-${idx}`}
x1={0}
y1={line.pos}
x2={width}
y2={line.pos}
stroke="rgba(255, 255, 255, 0.03)"
strokeWidth="1"
/>
)
))}
{/* 点阵网格 */}
{gridPattern.dots.map((dot, idx) => (
<circle
key={idx}
cx={dot.x}
cy={dot.y}
r={gridPattern.dotSize}
fill="rgba(255, 255, 255, 0.15)"
/>
))}
</svg>
);
};

View File

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

View File

@@ -1,182 +0,0 @@
import { React, useState, useRef, useEffect, type ReactNode, Icons } from '@esengine/editor-runtime';
const { GripVertical } = Icons;
interface DraggablePanelProps {
title: string | ReactNode;
icon?: ReactNode;
isVisible: boolean;
onClose: () => void;
width?: number;
maxHeight?: number;
initialPosition?: { x: number; y: number };
headerActions?: ReactNode;
children: ReactNode;
footer?: ReactNode | false;
}
/**
* 可拖动面板通用组件
* 提供标题栏拖动、关闭按钮等基础功能
*/
export const DraggablePanel: React.FC<DraggablePanelProps> = ({
title,
icon,
isVisible,
onClose,
width = 400,
maxHeight = 600,
initialPosition = { x: 20, y: 100 },
headerActions,
children,
footer
}) => {
const [position, setPosition] = useState(initialPosition);
const [isDragging, setIsDragging] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isVisible) return;
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
const newX = e.clientX - dragOffset.x;
const newY = e.clientY - dragOffset.y;
// 限制面板在视口内
const maxX = window.innerWidth - width;
const maxY = window.innerHeight - 100;
setPosition({
x: Math.max(0, Math.min(newX, maxX)),
y: Math.max(0, Math.min(newY, maxY))
});
};
const handleMouseUp = () => {
setIsDragging(false);
};
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, dragOffset, width]);
const handleMouseDown = (e: React.MouseEvent) => {
if (!panelRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top
});
setIsDragging(true);
};
if (!isVisible) return null;
return (
<div
ref={panelRef}
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: `${width}px`,
maxHeight: `${maxHeight}px`,
backgroundColor: '#1e1e1e',
border: '1px solid #3f3f3f',
borderRadius: '8px',
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
userSelect: isDragging ? 'none' : 'auto'
}}
>
{/* 可拖动标题栏 */}
<div
onMouseDown={handleMouseDown}
style={{
padding: '12px 16px',
borderBottom: '1px solid #3f3f3f',
backgroundColor: '#252525',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
cursor: isDragging ? 'grabbing' : 'grab',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<GripVertical size={14} color="#666" style={{ flexShrink: 0 }} />
{icon}
{typeof title === 'string' ? (
<span style={{
fontSize: '14px',
fontWeight: 'bold',
color: '#fff'
}}>
{title}
</span>
) : (
title
)}
</div>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{headerActions}
<button
onClick={onClose}
onMouseDown={(e) => e.stopPropagation()}
style={{
padding: '4px 8px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#ccc',
fontSize: '11px',
cursor: 'pointer'
}}
>
</button>
</div>
</div>
{/* 内容区域 */}
<div style={{
flex: 1,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column'
}}>
{children}
</div>
{/* 页脚 */}
{footer && (
<div style={{
borderTop: '1px solid #3f3f3f',
backgroundColor: '#252525',
borderBottomLeftRadius: '8px',
borderBottomRightRadius: '8px'
}}>
{footer}
</div>
)}
</div>
);
};

View File

@@ -1,83 +0,0 @@
import { React, useMemo } from '@esengine/editor-runtime';
import { ConnectionRenderer } from './ConnectionRenderer';
import { ConnectionViewData } from '../../types';
import { Node } from '../../domain/models/Node';
import { Connection } from '../../domain/models/Connection';
interface ConnectionLayerProps {
connections: Connection[];
nodes: Node[];
selectedConnection?: { from: string; to: string } | null;
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
onConnectionClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
onConnectionContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
}
export const ConnectionLayer: React.FC<ConnectionLayerProps> = ({
connections,
nodes,
selectedConnection,
getPortPosition,
onConnectionClick,
onConnectionContextMenu
}) => {
const nodeMap = useMemo(() => {
return new Map(nodes.map((node) => [node.id, node]));
}, [nodes]);
const connectionViewData = useMemo(() => {
return connections
.map((connection) => {
const fromNode = nodeMap.get(connection.from);
const toNode = nodeMap.get(connection.to);
if (!fromNode || !toNode) {
return null;
}
return { connection, fromNode, toNode };
})
.filter((item): item is NonNullable<typeof item> => item !== null);
}, [connections, nodeMap]);
const isConnectionSelected = (connection: { from: string; to: string }) => {
return selectedConnection?.from === connection.from &&
selectedConnection?.to === connection.to;
};
if (connectionViewData.length === 0) {
return null;
}
return (
<svg
className="connection-layer"
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
overflow: 'visible',
zIndex: 0
}}
>
<g style={{ pointerEvents: 'auto' }}>
{connectionViewData.map(({ connection, fromNode, toNode }) => {
const viewData: ConnectionViewData = {
connection,
isSelected: isConnectionSelected(connection)
};
return (
<ConnectionRenderer
key={`${connection.from}-${connection.to}`}
connectionData={viewData}
fromNode={fromNode}
toNode={toNode}
getPortPosition={getPortPosition}
onClick={onConnectionClick}
onContextMenu={onConnectionContextMenu}
/>
);
})}
</g>
</svg>
);
};

View File

@@ -1,164 +0,0 @@
import { React, useMemo } from '@esengine/editor-runtime';
import { ConnectionViewData } from '../../types';
import { Node } from '../../domain/models/Node';
interface ConnectionRendererProps {
connectionData: ConnectionViewData;
fromNode: Node;
toNode: Node;
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
onClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
onContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
}
const ConnectionRendererComponent: React.FC<ConnectionRendererProps> = ({
connectionData,
fromNode,
toNode,
getPortPosition,
onClick,
onContextMenu
}) => {
const { connection, isSelected } = connectionData;
const pathData = useMemo(() => {
let fromPos, toPos;
if (connection.connectionType === 'property') {
// 属性连接:使用 fromProperty 和 toProperty
fromPos = getPortPosition(connection.from, connection.fromProperty);
toPos = getPortPosition(connection.to, connection.toProperty);
} else {
// 节点连接:使用输出和输入端口
fromPos = getPortPosition(connection.from, undefined, 'output');
toPos = getPortPosition(connection.to, undefined, 'input');
}
if (!fromPos || !toPos) {
return null;
}
const x1 = fromPos.x;
const y1 = fromPos.y;
const x2 = toPos.x;
const y2 = toPos.y;
let pathD: string;
if (connection.connectionType === 'property') {
const controlX1 = x1 + (x2 - x1) * 0.5;
const controlX2 = x1 + (x2 - x1) * 0.5;
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
} else {
const controlY = y1 + (y2 - y1) * 0.5;
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
}
return {
path: pathD,
midX: (x1 + x2) / 2,
midY: (y1 + y2) / 2
};
}, [connection, fromNode, toNode, getPortPosition]);
const isPropertyConnection = connection.connectionType === 'property';
const color = isPropertyConnection ? '#ab47bc' : '#00bcd4';
const glowColor = isPropertyConnection ? 'rgba(171, 71, 188, 0.6)' : 'rgba(0, 188, 212, 0.6)';
const strokeColor = isSelected ? '#FFD700' : color;
const strokeWidth = isSelected ? 3.5 : 2.5;
const gradientId = `gradient-${connection.from}-${connection.to}`;
if (!pathData) {
return null;
}
const pathD = pathData.path;
const endPosMatch = pathD.match(/C [0-9.-]+ [0-9.-]+, [0-9.-]+ [0-9.-]+, ([0-9.-]+) ([0-9.-]+)/);
const endX = endPosMatch ? parseFloat(endPosMatch[1]) : 0;
const endY = endPosMatch ? parseFloat(endPosMatch[2]) : 0;
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(e, connection.from, connection.to);
};
const handleContextMenu = (e: React.MouseEvent) => {
e.stopPropagation();
onContextMenu?.(e, connection.from, connection.to);
};
return (
<g
className="connection"
onClick={handleClick}
onContextMenu={handleContextMenu}
style={{ cursor: 'pointer' }}
data-connection-from={connection.from}
data-connection-to={connection.to}
>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.8" />
<stop offset="50%" stopColor={strokeColor} stopOpacity="1" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.8" />
</linearGradient>
</defs>
<path
d={pathData.path}
fill="none"
stroke="transparent"
strokeWidth={24}
/>
<path
d={pathData.path}
fill="none"
stroke={glowColor}
strokeWidth={strokeWidth + 2}
strokeLinecap="round"
opacity={isSelected ? 0.4 : 0.2}
/>
<path
d={pathData.path}
fill="none"
stroke={`url(#${gradientId})`}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
<circle
cx={endX}
cy={endY}
r="5"
fill={strokeColor}
stroke="rgba(0, 0, 0, 0.3)"
strokeWidth="1"
/>
{isSelected && (
<>
<circle
cx={pathData.midX}
cy={pathData.midY}
r="8"
fill={strokeColor}
opacity="0.3"
/>
<circle
cx={pathData.midX}
cy={pathData.midY}
r="5"
fill={strokeColor}
stroke="rgba(0, 0, 0, 0.5)"
strokeWidth="2"
/>
</>
)}
</g>
);
};
export const ConnectionRenderer = ConnectionRendererComponent;

View File

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

View File

@@ -1,95 +0,0 @@
import { React, Icons } from '@esengine/editor-runtime';
const { Trash2, Replace, Plus } = Icons;
interface NodeContextMenuProps {
visible: boolean;
position: { x: number; y: number };
nodeId: string | null;
isBlackboardVariable?: boolean;
onReplaceNode?: () => void;
onDeleteNode?: () => void;
onCreateNode?: () => void;
}
export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
visible,
position,
nodeId,
isBlackboardVariable = false,
onReplaceNode,
onDeleteNode,
onCreateNode
}) => {
if (!visible) return null;
const menuItemStyle = {
padding: '8px 16px',
cursor: 'pointer',
color: '#cccccc',
fontSize: '13px',
transition: 'background-color 0.15s',
display: 'flex',
alignItems: 'center',
gap: '8px'
};
return (
<div
style={{
position: 'fixed',
left: position.x,
top: position.y,
backgroundColor: '#2d2d30',
border: '1px solid #454545',
borderRadius: '4px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
zIndex: 10000,
minWidth: '150px',
padding: '4px 0'
}}
onClick={(e) => e.stopPropagation()}
>
{nodeId ? (
<>
{onReplaceNode && (
<div
onClick={onReplaceNode}
style={menuItemStyle}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Replace size={14} />
</div>
)}
{onDeleteNode && (
<div
onClick={onDeleteNode}
style={{ ...menuItemStyle, color: '#f48771' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#5a1a1a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Trash2 size={14} />
</div>
)}
</>
) : (
<>
{onCreateNode && (
<div
onClick={onCreateNode}
style={menuItemStyle}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<Plus size={14} />
</div>
)}
</>
)}
</div>
);
};

View File

@@ -1,350 +0,0 @@
import { React, useRef, useEffect, useState, useMemo, Icons } from '@esengine/editor-runtime';
import type { LucideIcon } from '@esengine/editor-runtime';
import { NodeTemplate } from '@esengine/behavior-tree';
import { NodeFactory } from '../../infrastructure/factories/NodeFactory';
const { Search, X, ChevronDown, ChevronRight } = Icons;
interface QuickCreateMenuProps {
visible: boolean;
position: { x: number; y: number };
searchText: string;
selectedIndex: number;
mode: 'create' | 'replace';
iconMap: Record<string, LucideIcon>;
onSearchChange: (text: string) => void;
onIndexChange: (index: number) => void;
onNodeSelect: (template: NodeTemplate) => void;
onClose: () => void;
}
interface CategoryGroup {
category: string;
templates: NodeTemplate[];
isExpanded: boolean;
}
export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
visible,
position,
searchText,
selectedIndex,
iconMap,
onSearchChange,
onIndexChange,
onNodeSelect,
onClose
}) => {
const selectedNodeRef = useRef<HTMLDivElement>(null);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
const [shouldAutoScroll, setShouldAutoScroll] = useState(false);
const nodeFactory = useMemo(() => new NodeFactory(), []);
const allTemplates = useMemo(() => nodeFactory.getAllTemplates(), [nodeFactory]);
const searchTextLower = searchText.toLowerCase();
const filteredTemplates = searchTextLower
? allTemplates.filter((t: NodeTemplate) => {
const className = t.className || '';
return t.displayName.toLowerCase().includes(searchTextLower) ||
t.description.toLowerCase().includes(searchTextLower) ||
t.category.toLowerCase().includes(searchTextLower) ||
className.toLowerCase().includes(searchTextLower);
})
: allTemplates;
const categoryGroups: CategoryGroup[] = React.useMemo(() => {
const groups = new Map<string, NodeTemplate[]>();
filteredTemplates.forEach((template: NodeTemplate) => {
const category = template.category || '未分类';
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category)!.push(template);
});
return Array.from(groups.entries()).map(([category, templates]) => ({
category,
templates,
isExpanded: searchTextLower ? true : expandedCategories.has(category)
})).sort((a, b) => a.category.localeCompare(b.category));
}, [filteredTemplates, expandedCategories, searchTextLower]);
const flattenedTemplates = React.useMemo(() => {
return categoryGroups.flatMap((group) =>
group.isExpanded ? group.templates : []
);
}, [categoryGroups]);
const toggleCategory = (category: string) => {
setExpandedCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(category)) {
newSet.delete(category);
} else {
newSet.add(category);
}
return newSet;
});
};
useEffect(() => {
if (allTemplates.length > 0 && expandedCategories.size === 0) {
const categories = new Set(allTemplates.map((t) => t.category || '未分类'));
setExpandedCategories(categories);
}
}, [allTemplates, expandedCategories.size]);
useEffect(() => {
if (shouldAutoScroll && selectedNodeRef.current) {
selectedNodeRef.current.scrollIntoView({
block: 'nearest',
behavior: 'smooth'
});
setShouldAutoScroll(false);
}
}, [selectedIndex, shouldAutoScroll]);
if (!visible) return null;
let globalIndex = -1;
return (
<>
<style>{`
.quick-create-menu-list::-webkit-scrollbar {
width: 8px;
}
.quick-create-menu-list::-webkit-scrollbar-track {
background: #1e1e1e;
}
.quick-create-menu-list::-webkit-scrollbar-thumb {
background: #3c3c3c;
border-radius: 4px;
}
.quick-create-menu-list::-webkit-scrollbar-thumb:hover {
background: #4c4c4c;
}
.category-header {
transition: background-color 0.15s;
}
.category-header:hover {
background-color: #3c3c3c;
}
`}</style>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '300px',
maxHeight: '500px',
backgroundColor: '#2d2d2d',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* 搜索框 */}
<div style={{
padding: '12px',
borderBottom: '1px solid #3c3c3c',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<Search size={16} style={{ color: '#999', flexShrink: 0 }} />
<input
type="text"
placeholder="搜索节点..."
autoFocus
value={searchText}
onChange={(e) => {
onSearchChange(e.target.value);
onIndexChange(0);
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setShouldAutoScroll(true);
onIndexChange(Math.min(selectedIndex + 1, flattenedTemplates.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setShouldAutoScroll(true);
onIndexChange(Math.max(selectedIndex - 1, 0));
} else if (e.key === 'Enter' && flattenedTemplates.length > 0) {
e.preventDefault();
const selectedTemplate = flattenedTemplates[selectedIndex];
if (selectedTemplate) {
onNodeSelect(selectedTemplate);
}
}
}}
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: '#ccc',
fontSize: '14px',
padding: '4px'
}}
/>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: '#999',
cursor: 'pointer',
padding: '4px',
display: 'flex',
alignItems: 'center'
}}
>
<X size={16} />
</button>
</div>
{/* 节点列表 */}
<div
className="quick-create-menu-list"
style={{
flex: 1,
overflowY: 'auto',
padding: '4px'
}}
>
{categoryGroups.length === 0 ? (
<div style={{
padding: '20px',
textAlign: 'center',
color: '#666',
fontSize: '12px'
}}>
</div>
) : (
categoryGroups.map((group) => {
return (
<div key={group.category} style={{ marginBottom: '4px' }}>
<div
className="category-header"
onClick={() => toggleCategory(group.category)}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
backgroundColor: '#1e1e1e',
borderRadius: '3px',
cursor: 'pointer',
userSelect: 'none'
}}
>
{group.isExpanded ? (
<ChevronDown size={14} style={{ color: '#999', flexShrink: 0 }} />
) : (
<ChevronRight size={14} style={{ color: '#999', flexShrink: 0 }} />
)}
<span style={{
color: '#aaa',
fontSize: '12px',
fontWeight: '600',
flex: 1
}}>
{group.category}
</span>
<span style={{
color: '#666',
fontSize: '11px',
backgroundColor: '#2d2d2d',
padding: '2px 6px',
borderRadius: '10px'
}}>
{group.templates.length}
</span>
</div>
{group.isExpanded && (
<div style={{ paddingLeft: '8px', paddingTop: '4px' }}>
{group.templates.map((template: NodeTemplate) => {
globalIndex++;
const IconComponent = template.icon ? iconMap[template.icon] : null;
const className = template.className || '';
const isSelected = globalIndex === selectedIndex;
return (
<div
key={template.className || template.displayName}
ref={isSelected ? selectedNodeRef : null}
onClick={() => onNodeSelect(template)}
onMouseEnter={() => onIndexChange(globalIndex)}
style={{
padding: '8px 12px',
marginBottom: '4px',
backgroundColor: isSelected ? '#0e639c' : '#1e1e1e',
borderLeft: `3px solid ${template.color || '#666'}`,
borderRadius: '3px',
cursor: 'pointer',
transition: 'all 0.15s',
transform: isSelected ? 'translateX(2px)' : 'translateX(0)'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '4px'
}}>
{IconComponent && (
<IconComponent size={14} style={{ color: template.color || '#999', flexShrink: 0 }} />
)}
<div style={{ flex: 1 }}>
<div style={{
color: '#ccc',
fontSize: '13px',
fontWeight: '500',
marginBottom: '2px'
}}>
{template.displayName}
</div>
{className && (
<div style={{
color: '#666',
fontSize: '10px',
fontFamily: 'Consolas, Monaco, monospace',
opacity: 0.8
}}>
{className}
</div>
)}
</div>
</div>
<div style={{
fontSize: '11px',
color: '#999',
lineHeight: '1.4'
}}>
{template.description}
</div>
</div>
);
})}
</div>
)}
</div>
);
})
)}
</div>
</div>
</>
);
};

View File

@@ -1,405 +0,0 @@
import { React, Icons } from '@esengine/editor-runtime';
import type { LucideIcon } from '@esengine/editor-runtime';
import { PropertyDefinition } from '@esengine/behavior-tree';
import { Node as BehaviorTreeNodeType } from '../../domain/models/Node';
import { Connection } from '../../domain/models/Connection';
import { ROOT_NODE_ID } from '../../domain/constants/RootNode';
import type { NodeExecutionStatus } from '../../stores';
import { BehaviorTreeExecutor } from '../../utils/BehaviorTreeExecutor';
import { BlackboardValue } from '../../domain/models/Blackboard';
const { TreePine, Database, AlertTriangle, AlertCircle } = Icons;
type BlackboardVariables = Record<string, BlackboardValue>;
interface BehaviorTreeNodeProps {
node: BehaviorTreeNodeType;
isSelected: boolean;
isBeingDragged: boolean;
dragDelta: { dx: number; dy: number };
uncommittedNodeIds: Set<string>;
blackboardVariables: BlackboardVariables;
initialBlackboardVariables: BlackboardVariables;
isExecuting: boolean;
executionStatus?: NodeExecutionStatus;
executionOrder?: number;
connections: Connection[];
nodes: BehaviorTreeNodeType[];
executorRef: React.RefObject<BehaviorTreeExecutor | null>;
iconMap: Record<string, LucideIcon>;
draggingNodeId: string | null;
onNodeClick: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
onContextMenu: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
onNodeMouseDown: (e: React.MouseEvent, nodeId: string) => void;
onNodeMouseUpForConnection: (e: React.MouseEvent, nodeId: string) => void;
onPortMouseDown: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
onPortMouseUp: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
}
const BehaviorTreeNodeComponent: React.FC<BehaviorTreeNodeProps> = ({
node,
isSelected,
isBeingDragged,
dragDelta,
uncommittedNodeIds,
blackboardVariables,
initialBlackboardVariables,
isExecuting,
executionStatus,
executionOrder,
connections,
nodes,
executorRef,
iconMap,
draggingNodeId,
onNodeClick,
onContextMenu,
onNodeMouseDown,
onNodeMouseUpForConnection,
onPortMouseDown,
onPortMouseUp
}) => {
const isRoot = node.id === ROOT_NODE_ID;
const isBlackboardVariable = node.data.nodeType === 'blackboard-variable';
const posX = node.position.x + (isBeingDragged ? dragDelta.dx : 0);
const posY = node.position.y + (isBeingDragged ? dragDelta.dy : 0);
const isUncommitted = uncommittedNodeIds.has(node.id);
const nodeClasses = [
'bt-node',
isSelected && 'selected',
isRoot && 'root',
isUncommitted && 'uncommitted',
executionStatus && executionStatus !== 'idle' && executionStatus
].filter(Boolean).join(' ');
return (
<div
key={node.id}
data-node-id={node.id}
className={nodeClasses}
onClick={(e) => onNodeClick(e, node)}
onContextMenu={(e) => onContextMenu(e, node)}
onMouseDown={(e) => onNodeMouseDown(e, node.id)}
onMouseUp={(e) => onNodeMouseUpForConnection(e, node.id)}
onDragStart={(e) => e.preventDefault()}
style={{
left: posX,
top: posY,
transform: 'translate(-50%, -50%)',
cursor: isRoot ? 'default' : (draggingNodeId === node.id ? 'grabbing' : 'grab'),
transition: draggingNodeId === node.id ? 'none' : 'all 0.2s',
zIndex: isRoot ? 50 : (draggingNodeId === node.id ? 100 : (isSelected ? 10 : 1))
}}
>
{/* 执行顺序角标 - 使用绝对定位,不影响节点布局 */}
{executionOrder !== undefined && (
<div
className="bt-node-execution-badge"
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
backgroundColor: '#2196f3',
color: '#fff',
borderRadius: '50%',
minWidth: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontWeight: 'bold',
padding: '0 6px',
boxShadow: '0 2px 8px rgba(33, 150, 243, 0.5)',
border: '2px solid #1a1a1d',
zIndex: 10,
pointerEvents: 'none'
}}
title={`执行顺序: ${executionOrder}`}
>
{executionOrder}
</div>
)}
{isBlackboardVariable ? (
(() => {
const varName = node.data.variableName as string;
const currentValue = blackboardVariables[varName];
const initialValue = initialBlackboardVariables[varName];
const isModified = isExecuting && JSON.stringify(currentValue) !== JSON.stringify(initialValue);
return (
<>
<div className="bt-node-header blackboard">
<Database size={16} className="bt-node-header-icon" />
<div className="bt-node-header-title">
{varName || 'Variable'}
</div>
{isModified && (
<span style={{
fontSize: '9px',
color: '#ffbb00',
backgroundColor: 'rgba(255, 187, 0, 0.2)',
padding: '2px 4px',
borderRadius: '2px',
marginLeft: '4px'
}}>
</span>
)}
</div>
<div className="bt-node-body">
<div
className="bt-node-blackboard-value"
style={{
backgroundColor: isModified ? 'rgba(255, 187, 0, 0.15)' : 'transparent',
border: isModified ? '1px solid rgba(255, 187, 0, 0.3)' : 'none',
borderRadius: '2px',
padding: '2px 4px'
}}
title={isModified ? `初始值: ${JSON.stringify(initialValue)}\n当前值: ${JSON.stringify(currentValue)}` : undefined}
>
{JSON.stringify(currentValue)}
</div>
</div>
<div
data-port="true"
data-node-id={node.id}
data-property="__value__"
data-port-type="variable-output"
onMouseDown={(e) => onPortMouseDown(e, node.id, '__value__')}
onMouseUp={(e) => onPortMouseUp(e, node.id, '__value__')}
className="bt-node-port bt-node-port-variable-output"
title="Output"
/>
</>
);
})()
) : (
<>
<div className={`bt-node-header ${isRoot ? 'root' : (node.template.type || 'action')}`}>
{isRoot ? (
<TreePine size={16} className="bt-node-header-icon" />
) : (
node.template.icon && (() => {
const IconComponent = iconMap[node.template.icon];
return IconComponent ? (
<IconComponent size={16} className="bt-node-header-icon" />
) : (
<span className="bt-node-header-icon">{node.template.icon}</span>
);
})()
)}
<div className="bt-node-header-title">
<div>{isRoot ? 'ROOT' : node.template.displayName}</div>
<div className="bt-node-id" title={node.id}>
#{node.id}
</div>
</div>
{!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
<div
className="bt-node-missing-executor-warning"
style={{
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
cursor: 'help',
pointerEvents: 'auto',
position: 'relative'
}}
onClick={(e) => e.stopPropagation()}
>
<AlertCircle
size={14}
style={{
color: '#f44336',
flexShrink: 0
}}
/>
<div className="bt-node-missing-executor-tooltip">
"{node.template.className}"
</div>
</div>
)}
{isUncommitted && (
<div
className="bt-node-uncommitted-warning"
style={{
marginLeft: (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) ? '4px' : 'auto',
display: 'flex',
alignItems: 'center',
cursor: 'help',
pointerEvents: 'auto',
position: 'relative'
}}
onClick={(e) => e.stopPropagation()}
>
<AlertTriangle
size={14}
style={{
color: '#ff5722',
flexShrink: 0
}}
/>
<div className="bt-node-uncommitted-tooltip">
</div>
</div>
)}
{!isRoot && !isUncommitted && node.template.type === 'composite' &&
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) &&
!nodes.some((n) =>
connections.some((c) => c.from === node.id && c.to === n.id)
) && (
<div
className="bt-node-empty-warning-container"
style={{
marginLeft: ((!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) || isUncommitted) ? '4px' : 'auto',
display: 'flex',
alignItems: 'center',
cursor: 'help',
pointerEvents: 'auto',
position: 'relative'
}}
onClick={(e) => e.stopPropagation()}
>
<AlertTriangle
size={14}
style={{
color: '#ff9800',
flexShrink: 0
}}
/>
<div className="bt-node-empty-warning-tooltip">
</div>
</div>
)}
</div>
<div className="bt-node-body">
{!isRoot && (
<div className="bt-node-category">
{node.template.category}
</div>
)}
{node.template.properties.length > 0 && (
<div className="bt-node-properties">
{node.template.properties.map((prop: PropertyDefinition, idx: number) => {
const hasConnection = connections.some(
(conn: Connection) => conn.toProperty === prop.name && conn.to === node.id
);
const propValue = node.data[prop.name];
return (
<div key={idx} className="bt-node-property">
<div
data-port="true"
data-node-id={node.id}
data-property={prop.name}
data-port-type="property-input"
onMouseDown={(e) => onPortMouseDown(e, node.id, prop.name)}
onMouseUp={(e) => onPortMouseUp(e, node.id, prop.name)}
className={`bt-node-port bt-node-port-property ${hasConnection ? 'connected' : ''}`}
title={prop.description || prop.name}
/>
<span
className="bt-node-property-label"
title={prop.description}
>
{prop.name}:
</span>
{propValue !== undefined && (
<span className="bt-node-property-value">
{String(propValue)}
</span>
)}
</div>
);
})}
</div>
)}
</div>
{!isRoot && (
<div
data-port="true"
data-node-id={node.id}
data-port-type="node-input"
onMouseDown={(e) => onPortMouseDown(e, node.id)}
onMouseUp={(e) => onPortMouseUp(e, node.id)}
className="bt-node-port bt-node-port-input"
title="Input"
/>
)}
{(isRoot || node.template.type === 'composite' || node.template.type === 'decorator') &&
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) && (
<div
data-port="true"
data-node-id={node.id}
data-port-type="node-output"
onMouseDown={(e) => onPortMouseDown(e, node.id)}
onMouseUp={(e) => onPortMouseUp(e, node.id)}
className="bt-node-port bt-node-port-output"
title="Output"
/>
)}
</>
)}
</div>
);
};
/**
* 使用 React.memo 优化节点组件性能
* 只在关键 props 变化时重新渲染
*/
export const BehaviorTreeNode = React.memo(BehaviorTreeNodeComponent, (prevProps, nextProps) => {
// 如果节点本身变化,需要重新渲染
if (prevProps.node.id !== nextProps.node.id ||
prevProps.node.position.x !== nextProps.node.position.x ||
prevProps.node.position.y !== nextProps.node.position.y ||
prevProps.node.template.className !== nextProps.node.template.className) {
return false;
}
if (prevProps.isSelected !== nextProps.isSelected ||
prevProps.isBeingDragged !== nextProps.isBeingDragged ||
prevProps.executionStatus !== nextProps.executionStatus ||
prevProps.executionOrder !== nextProps.executionOrder ||
prevProps.draggingNodeId !== nextProps.draggingNodeId) {
return false;
}
// 如果正在被拖拽,且 dragDelta 变化,需要重新渲染
if (nextProps.isBeingDragged &&
(prevProps.dragDelta.dx !== nextProps.dragDelta.dx ||
prevProps.dragDelta.dy !== nextProps.dragDelta.dy)) {
return false;
}
// 如果执行状态变化,需要重新渲染
if (prevProps.isExecuting !== nextProps.isExecuting) {
return false;
}
// 检查 uncommittedNodeIds 中是否包含当前节点
const prevUncommitted = prevProps.uncommittedNodeIds.has(nextProps.node.id);
const nextUncommitted = nextProps.uncommittedNodeIds.has(nextProps.node.id);
if (prevUncommitted !== nextUncommitted) {
return false;
}
// 节点数据变化时需要重新渲染
if (JSON.stringify(prevProps.node.data) !== JSON.stringify(nextProps.node.data)) {
return false;
}
// 其他情况不重新渲染
return true;
});

View File

@@ -1,221 +0,0 @@
import { React, useMemo, Icons } from '@esengine/editor-runtime';
import type { LucideIcon } from '@esengine/editor-runtime';
import { NodeViewData } from '../../types';
const LucideIcons = Icons;
/**
* 图标映射
*/
const iconMap: Record<string, LucideIcon> = {
TreePine: LucideIcons.TreePine,
GitBranch: LucideIcons.GitBranch,
Shuffle: LucideIcons.Shuffle,
Repeat: LucideIcons.Repeat,
RotateCcw: LucideIcons.RotateCcw,
FlipHorizontal: LucideIcons.FlipHorizontal,
CheckCircle: LucideIcons.CheckCircle,
XCircle: LucideIcons.XCircle,
Play: LucideIcons.Play,
Pause: LucideIcons.Pause,
Square: LucideIcons.Square,
Circle: LucideIcons.Circle,
Diamond: LucideIcons.Diamond,
Box: LucideIcons.Box,
Flag: LucideIcons.Flag,
Target: LucideIcons.Target
};
/**
* 节点渲染器属性
*/
interface BehaviorTreeNodeRendererProps {
/**
* 节点视图数据
*/
nodeData: NodeViewData;
/**
* 节点点击事件
*/
onClick?: (e: React.MouseEvent, nodeId: string) => void;
/**
* 节点双击事件
*/
onDoubleClick?: (e: React.MouseEvent, nodeId: string) => void;
/**
* 节点右键事件
*/
onContextMenu?: (e: React.MouseEvent, nodeId: string) => void;
/**
* 鼠标按下事件
*/
onMouseDown?: (e: React.MouseEvent, nodeId: string) => void;
}
/**
* 行为树节点渲染器
* 负责单个节点的渲染
*/
export const BehaviorTreeNodeRenderer: React.FC<BehaviorTreeNodeRendererProps> = ({
nodeData,
onClick,
onDoubleClick,
onContextMenu,
onMouseDown
}) => {
const { node, isSelected, isDragging, executionStatus } = nodeData;
const { template, position } = node;
const IconComponent = iconMap[template.icon || 'Box'] || LucideIcons.Box;
const nodeStyle = useMemo(() => {
let borderColor = template.color || '#4a9eff';
const backgroundColor = '#2a2a2a';
let boxShadow = 'none';
if (isSelected) {
boxShadow = `0 0 0 2px ${borderColor}`;
}
if (executionStatus === 'running') {
borderColor = '#ffa500';
boxShadow = `0 0 10px ${borderColor}`;
} else if (executionStatus === 'success') {
borderColor = '#00ff00';
} else if (executionStatus === 'failure') {
borderColor = '#ff0000';
}
return {
position: 'absolute' as const,
left: position.x,
top: position.y,
minWidth: '180px',
padding: '12px',
backgroundColor,
borderRadius: '8px',
border: `2px solid ${borderColor}`,
boxShadow,
cursor: 'pointer',
userSelect: 'none' as const,
transition: 'box-shadow 0.2s',
opacity: isDragging ? 0.7 : 1
};
}, [template.color, position, isSelected, isDragging, executionStatus]);
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(e, node.id);
};
const handleDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onDoubleClick?.(e, node.id);
};
const handleContextMenu = (e: React.MouseEvent) => {
e.stopPropagation();
onContextMenu?.(e, node.id);
};
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation();
onMouseDown?.(e, node.id);
};
return (
<div
className="behavior-tree-node"
style={nodeStyle}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onContextMenu={handleContextMenu}
onMouseDown={handleMouseDown}
data-node-id={node.id}
>
{/* 节点头部 */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '4px'
}}>
<IconComponent size={20} color={template.color || '#4a9eff'} />
<div style={{
fontSize: '14px',
fontWeight: 'bold',
color: '#ffffff',
flex: 1
}}>
{template.displayName}
</div>
</div>
{/* 节点类型 */}
{template.category && (
<div style={{
fontSize: '11px',
color: '#888888',
marginBottom: '4px'
}}>
{template.category}
</div>
)}
{/* 节点描述 */}
{template.description && (
<div style={{
fontSize: '12px',
color: '#cccccc',
marginTop: '8px',
lineHeight: '1.4'
}}>
{template.description}
</div>
)}
{/* 输入连接点 */}
<div
className="node-input-pin"
style={{
position: 'absolute',
top: '50%',
left: '-6px',
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: template.color || '#4a9eff',
border: '2px solid #1a1a1a',
transform: 'translateY(-50%)',
cursor: 'pointer'
}}
data-pin-type="input"
data-node-id={node.id}
/>
{/* 输出连接点 */}
<div
className="node-output-pin"
style={{
position: 'absolute',
top: '50%',
right: '-6px',
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: template.color || '#4a9eff',
border: '2px solid #1a1a1a',
transform: 'translateY(-50%)',
cursor: 'pointer'
}}
data-pin-type="output"
data-node-id={node.id}
/>
</div>
);
};

View File

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

View File

@@ -1,126 +0,0 @@
/* 行为树编辑器面板样式 */
.behavior-tree-editor-panel {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background-color: #1a1a1a;
}
/* 空状态 */
.behavior-tree-editor-empty {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background-color: #1a1a1a;
}
.empty-state {
text-align: center;
color: #666;
}
.empty-state svg {
color: #444;
margin-bottom: 16px;
}
.empty-state p {
margin: 8px 0;
font-size: 14px;
}
.empty-state .hint {
font-size: 12px;
color: #555;
}
/* 工具栏 */
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 16px;
background-color: #2d2d30;
border-bottom: 1px solid #3e3e42;
}
.toolbar-left,
.toolbar-center,
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.file-name {
color: #cccccc;
font-size: 14px;
font-weight: 500;
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background-color: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: #cccccc;
cursor: pointer;
transition: all 0.2s;
}
.toolbar-btn:hover:not(:disabled) {
background-color: #3e3e42;
border-color: #464647;
}
.toolbar-btn:active:not(:disabled) {
background-color: #2a2d2e;
}
.toolbar-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* 画布容器 */
.editor-canvas-container {
flex: 1;
position: relative;
overflow: hidden;
}
/* 节点层 */
.nodes-layer {
position: relative;
width: 100%;
height: 100%;
}
/* 行为树画布 */
.behavior-tree-canvas {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.canvas-grid {
position: absolute;
inset: 0;
pointer-events: none;
}
.canvas-content {
position: absolute;
width: 100%;
height: 100%;
}

View File

@@ -1,312 +0,0 @@
import {
React,
useState,
useCallback,
useEffect,
Core,
createLogger,
MessageHub,
open,
save,
Icons,
} from '@esengine/editor-runtime';
import { useBehaviorTreeDataStore } from '../../stores';
import { BehaviorTreeEditor } from '../BehaviorTreeEditor';
import { BehaviorTreeService } from '../../services/BehaviorTreeService';
import { showToast } from '../../services/NotificationService';
import { Node as BehaviorTreeNode } from '../../domain/models/Node';
import { BehaviorTree } from '../../domain/models/BehaviorTree';
import './BehaviorTreeEditorPanel.css';
const { FolderOpen } = Icons;
const logger = createLogger('BehaviorTreeEditorPanel');
/**
* 行为树编辑器面板组件
* 提供完整的行为树编辑功能,包括:
* - 节点的创建、删除、移动
* - 连接管理
* - 黑板变量管理
* - 文件保存和加载
*/
interface BehaviorTreeEditorPanelProps {
/** 项目路径,用于文件系统操作 */
projectPath?: string | null;
/** 导出对话框打开回调 */
onOpenExportDialog?: () => void;
/** 获取可用文件列表回调 */
onGetAvailableFiles?: () => string[];
}
export const BehaviorTreeEditorPanel: React.FC<BehaviorTreeEditorPanelProps> = ({
projectPath,
onOpenExportDialog
// onGetAvailableFiles - 保留用于未来的批量导出功能
}) => {
const isOpen = useBehaviorTreeDataStore((state) => state.isOpen);
const blackboardVariables = useBehaviorTreeDataStore((state) => state.blackboardVariables);
// 文件状态管理
const [currentFilePath, setCurrentFilePath] = useState<string | null>(null);
const [currentFileName, setCurrentFileName] = useState<string>('');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string>('');
// 监听树的变化来检测未保存更改
const tree = useBehaviorTreeDataStore((state) => state.tree);
const storeFilePath = useBehaviorTreeDataStore((state) => state.currentFilePath);
const storeFileName = useBehaviorTreeDataStore((state) => state.currentFileName);
// 初始化时从 store 读取文件信息(解决时序问题)
useEffect(() => {
if (storeFilePath && !currentFilePath) {
setCurrentFilePath(storeFilePath);
setCurrentFileName(storeFileName);
const loadedTree = useBehaviorTreeDataStore.getState().tree;
setLastSavedSnapshot(JSON.stringify(loadedTree));
setHasUnsavedChanges(false);
}
}, [storeFilePath, storeFileName, currentFilePath]);
useEffect(() => {
if (isOpen && lastSavedSnapshot) {
const currentSnapshot = JSON.stringify(tree);
setHasUnsavedChanges(currentSnapshot !== lastSavedSnapshot);
}
}, [tree, lastSavedSnapshot, isOpen]);
useEffect(() => {
try {
const messageHub = Core.services.resolve(MessageHub);
// 订阅文件打开事件
const unsubscribeFileOpened = messageHub.subscribe('behavior-tree:file-opened', (data: { filePath: string; fileName: string }) => {
setCurrentFilePath(data.filePath);
setCurrentFileName(data.fileName);
const loadedTree = useBehaviorTreeDataStore.getState().tree;
setLastSavedSnapshot(JSON.stringify(loadedTree));
setHasUnsavedChanges(false);
});
// 订阅节点属性更改事件
const unsubscribePropertyChanged = messageHub.subscribe('behavior-tree:node-property-changed',
(data: { nodeId: string; propertyName: string; value: any }) => {
const state = useBehaviorTreeDataStore.getState();
const node = state.getNode(data.nodeId);
if (node) {
const newData = { ...node.data, [data.propertyName]: data.value };
// 更新节点数据
const updatedNode = new BehaviorTreeNode(
node.id,
node.template,
newData,
node.position,
Array.from(node.children)
);
// 更新树
const nodes = state.getNodes().map((n) =>
n.id === data.nodeId ? updatedNode : n
);
const newTree = new BehaviorTree(
nodes,
state.getConnections(),
state.getBlackboard(),
state.getRootNodeId()
);
state.setTree(newTree);
setHasUnsavedChanges(true);
// 强制刷新画布
state.triggerForceUpdate();
}
}
);
return () => {
unsubscribeFileOpened();
unsubscribePropertyChanged();
};
} catch (error) {
logger.error('Failed to subscribe to events:', error);
}
}, []);
const handleNodeSelect = useCallback((node: BehaviorTreeNode) => {
try {
const messageHub = Core.services.resolve(MessageHub);
messageHub.publish('behavior-tree:node-selected', { data: node });
} catch (error) {
logger.error('Failed to publish node selection:', error);
}
}, []);
const handleSave = useCallback(async () => {
try {
let filePath = currentFilePath;
if (!filePath) {
const selected = await save({
filters: [{ name: 'Behavior Tree', extensions: ['btree'] }],
defaultPath: projectPath || undefined,
title: '保存行为树'
});
if (!selected) return;
filePath = selected;
}
const service = Core.services.resolve(BehaviorTreeService);
await service.saveToFile(filePath);
setCurrentFilePath(filePath);
const fileName = filePath.split(/[\\/]/).pop()?.replace('.btree', '') || 'Untitled';
setCurrentFileName(fileName);
setLastSavedSnapshot(JSON.stringify(tree));
setHasUnsavedChanges(false);
showToast(`文件已保存: ${fileName}.btree`, 'success');
} catch (error) {
logger.error('Failed to save file:', error);
showToast(`保存失败: ${error}`, 'error');
}
}, [currentFilePath, projectPath, tree]);
const handleOpen = useCallback(async () => {
try {
if (hasUnsavedChanges) {
const confirmed = window.confirm('当前文件有未保存的更改,是否继续打开新文件?');
if (!confirmed) return;
}
const selected = await open({
filters: [{ name: 'Behavior Tree', extensions: ['btree'] }],
multiple: false,
directory: false,
defaultPath: projectPath || undefined,
title: '打开行为树'
});
if (!selected) return;
const filePath = selected as string;
const service = Core.services.resolve(BehaviorTreeService);
await service.loadFromFile(filePath);
setCurrentFilePath(filePath);
const fileName = filePath.split(/[\\/]/).pop()?.replace('.btree', '') || 'Untitled';
setCurrentFileName(fileName);
const loadedTree = useBehaviorTreeDataStore.getState().tree;
setLastSavedSnapshot(JSON.stringify(loadedTree));
setHasUnsavedChanges(false);
showToast(`文件已打开: ${fileName}.btree`, 'success');
} catch (error) {
logger.error('Failed to open file:', error);
showToast(`打开失败: ${error}`, 'error');
}
}, [hasUnsavedChanges, projectPath]);
const handleExport = useCallback(() => {
if (onOpenExportDialog) {
onOpenExportDialog();
return;
}
try {
const messageHub = Core.services.resolve(MessageHub);
messageHub.publish('compiler:open-dialog', {
compilerId: 'behavior-tree',
currentFileName: currentFileName || undefined,
projectPath: projectPath || undefined
});
} catch (error) {
logger.error('Failed to open export dialog:', error);
showToast(`无法打开导出对话框: ${error}`, 'error');
}
}, [onOpenExportDialog, currentFileName, projectPath]);
const handleCopyToClipboard = useCallback(async () => {
try {
const store = useBehaviorTreeDataStore.getState();
const metadata = { name: currentFileName || 'Untitled', description: '' };
const jsonContent = store.exportToJSON(metadata);
await navigator.clipboard.writeText(jsonContent);
showToast('已复制到剪贴板', 'success');
} catch (error) {
logger.error('Failed to copy to clipboard:', error);
showToast(`复制失败: ${error}`, 'error');
}
}, [currentFileName]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
handleSave();
}
if (e.ctrlKey && e.key === 'o') {
e.preventDefault();
handleOpen();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSave, handleOpen]);
if (!isOpen) {
return (
<div className="behavior-tree-editor-empty">
<div className="empty-state">
<FolderOpen size={48} />
<p>No behavior tree file opened</p>
<p className="hint">Double-click a .btree file to edit</p>
<button
onClick={handleOpen}
style={{
marginTop: '16px',
padding: '8px 16px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
fontSize: '13px',
display: 'inline-flex',
alignItems: 'center',
gap: '8px'
}}
>
<FolderOpen size={16} />
</button>
</div>
</div>
);
}
return (
<div className="behavior-tree-editor-panel">
<BehaviorTreeEditor
blackboardVariables={blackboardVariables}
projectPath={projectPath}
showToolbar={true}
currentFileName={currentFileName}
hasUnsavedChanges={hasUnsavedChanges}
onNodeSelect={handleNodeSelect}
onSave={handleSave}
onOpen={handleOpen}
onExport={handleExport}
onCopyToClipboard={handleCopyToClipboard}
/>
</div>
);
};

View File

@@ -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;
}

View File

@@ -1,479 +0,0 @@
import { React, Icons } from '@esengine/editor-runtime';
const { Play, Pause, Square, SkipForward, Undo, Redo, ZoomIn, Save, FolderOpen, Download, Clipboard, Home } = Icons;
type ExecutionMode = 'idle' | 'running' | 'paused';
interface EditorToolbarProps {
executionMode: ExecutionMode;
canUndo: boolean;
canRedo: boolean;
hasUnsavedChanges?: boolean;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
onStep: () => void;
onReset: () => void;
onUndo: () => void;
onRedo: () => void;
onResetView: () => void;
onSave?: () => void;
onOpen?: () => void;
onExport?: () => void;
onCopyToClipboard?: () => void;
onGoToRoot?: () => void;
}
export const EditorToolbar: React.FC<EditorToolbarProps> = ({
executionMode,
canUndo,
canRedo,
hasUnsavedChanges = false,
onPlay,
onPause,
onStop,
onStep,
onReset,
onUndo,
onRedo,
onResetView,
onSave,
onOpen,
onExport,
onCopyToClipboard,
onGoToRoot
}) => {
return (
<div style={{
position: 'absolute',
top: '12px',
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
gap: '6px',
backgroundColor: '#2a2a2a',
padding: '6px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
border: '1px solid #3f3f3f',
zIndex: 100
}}>
{/* 文件操作组 */}
<div style={{
display: 'flex',
gap: '4px',
padding: '2px',
backgroundColor: '#1e1e1e',
borderRadius: '6px'
}}>
{onOpen && (
<button
onClick={onOpen}
style={{
padding: '6px 8px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#ccc',
cursor: 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="打开文件 (Ctrl+O)"
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
>
<FolderOpen size={14} />
</button>
)}
{onSave && (
<button
onClick={onSave}
style={{
padding: '6px 8px',
backgroundColor: hasUnsavedChanges ? '#2563eb' : '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: hasUnsavedChanges ? '#fff' : '#ccc',
cursor: 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title={`保存 (Ctrl+S)${hasUnsavedChanges ? ' - 有未保存的更改' : ''}`}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = hasUnsavedChanges ? '#1d4ed8' : '#4a4a4a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = hasUnsavedChanges ? '#2563eb' : '#3c3c3c'}
>
<Save size={14} />
</button>
)}
{onExport && (
<button
onClick={onExport}
style={{
padding: '6px 8px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#ccc',
cursor: 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="导出运行时配置"
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
>
<Download size={14} />
</button>
)}
{onCopyToClipboard && (
<button
onClick={onCopyToClipboard}
style={{
padding: '6px 8px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#ccc',
cursor: 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="复制JSON到剪贴板"
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
>
<Clipboard size={14} />
</button>
)}
</div>
{/* 分隔符 */}
<div style={{
width: '1px',
backgroundColor: '#444',
margin: '2px 0'
}} />
{/* 执行控制组 */}
<div style={{
display: 'flex',
gap: '4px',
padding: '2px',
backgroundColor: '#1e1e1e',
borderRadius: '6px'
}}>
{/* 播放按钮 */}
<button
onClick={onPlay}
disabled={executionMode === 'running'}
style={{
padding: '6px 10px',
backgroundColor: executionMode === 'running' ? '#2a2a2a' : '#16a34a',
border: 'none',
borderRadius: '4px',
color: executionMode === 'running' ? '#666' : '#fff',
cursor: executionMode === 'running' ? 'not-allowed' : 'pointer',
fontSize: '13px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.15s'
}}
title="运行 (Play)"
onMouseEnter={(e) => {
if (executionMode !== 'running') {
e.currentTarget.style.backgroundColor = '#15803d';
}
}}
onMouseLeave={(e) => {
if (executionMode !== 'running') {
e.currentTarget.style.backgroundColor = '#16a34a';
}
}}
>
<Play size={14} fill="currentColor" />
</button>
{/* 暂停按钮 */}
<button
onClick={onPause}
disabled={executionMode === 'idle'}
style={{
padding: '6px 10px',
backgroundColor: executionMode === 'idle' ? '#2a2a2a' : '#f59e0b',
border: 'none',
borderRadius: '4px',
color: executionMode === 'idle' ? '#666' : '#fff',
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title={executionMode === 'paused' ? '继续' : '暂停'}
onMouseEnter={(e) => {
if (executionMode !== 'idle') {
e.currentTarget.style.backgroundColor = '#d97706';
}
}}
onMouseLeave={(e) => {
if (executionMode !== 'idle') {
e.currentTarget.style.backgroundColor = '#f59e0b';
}
}}
>
{executionMode === 'paused' ? <Play size={14} fill="currentColor" /> : <Pause size={14} fill="currentColor" />}
</button>
{/* 停止按钮 */}
<button
onClick={onStop}
disabled={executionMode === 'idle'}
style={{
padding: '6px 10px',
backgroundColor: executionMode === 'idle' ? '#2a2a2a' : '#dc2626',
border: 'none',
borderRadius: '4px',
color: executionMode === 'idle' ? '#666' : '#fff',
cursor: executionMode === 'idle' ? 'not-allowed' : 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="停止"
onMouseEnter={(e) => {
if (executionMode !== 'idle') {
e.currentTarget.style.backgroundColor = '#b91c1c';
}
}}
onMouseLeave={(e) => {
if (executionMode !== 'idle') {
e.currentTarget.style.backgroundColor = '#dc2626';
}
}}
>
<Square size={14} fill="currentColor" />
</button>
{/* 单步执行按钮 */}
<button
onClick={onStep}
disabled={executionMode !== 'idle' && executionMode !== 'paused'}
style={{
padding: '6px 10px',
backgroundColor: (executionMode !== 'idle' && executionMode !== 'paused') ? '#2a2a2a' : '#3b82f6',
border: 'none',
borderRadius: '4px',
color: (executionMode !== 'idle' && executionMode !== 'paused') ? '#666' : '#fff',
cursor: (executionMode !== 'idle' && executionMode !== 'paused') ? 'not-allowed' : 'pointer',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="单步执行"
onMouseEnter={(e) => {
if (executionMode === 'idle' || executionMode === 'paused') {
e.currentTarget.style.backgroundColor = '#2563eb';
}
}}
onMouseLeave={(e) => {
if (executionMode === 'idle' || executionMode === 'paused') {
e.currentTarget.style.backgroundColor = '#3b82f6';
}
}}
>
<SkipForward size={14} />
</button>
</div>
{/* 分隔符 */}
<div style={{
width: '1px',
backgroundColor: '#444',
margin: '2px 0'
}} />
{/* 视图控制 */}
<button
onClick={onResetView}
style={{
padding: '6px 10px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#ccc',
cursor: 'pointer',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.15s'
}}
title="重置视图 (滚轮缩放, Alt+拖动平移)"
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
>
<ZoomIn size={13} />
<span>Reset View</span>
</button>
{/* 分隔符 */}
<div style={{
width: '1px',
backgroundColor: '#444',
margin: '2px 0'
}} />
{/* 历史控制组 */}
<div style={{
display: 'flex',
gap: '4px',
padding: '2px',
backgroundColor: '#1e1e1e',
borderRadius: '6px'
}}>
<button
onClick={onUndo}
disabled={!canUndo}
style={{
padding: '6px 8px',
backgroundColor: canUndo ? '#3c3c3c' : '#2a2a2a',
border: 'none',
borderRadius: '4px',
color: canUndo ? '#ccc' : '#666',
cursor: canUndo ? 'pointer' : 'not-allowed',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="撤销 (Ctrl+Z)"
onMouseEnter={(e) => {
if (canUndo) {
e.currentTarget.style.backgroundColor = '#4a4a4a';
}
}}
onMouseLeave={(e) => {
if (canUndo) {
e.currentTarget.style.backgroundColor = '#3c3c3c';
}
}}
>
<Undo size={14} />
</button>
<button
onClick={onRedo}
disabled={!canRedo}
style={{
padding: '6px 8px',
backgroundColor: canRedo ? '#3c3c3c' : '#2a2a2a',
border: 'none',
borderRadius: '4px',
color: canRedo ? '#ccc' : '#666',
cursor: canRedo ? 'pointer' : 'not-allowed',
fontSize: '13px',
display: 'flex',
alignItems: 'center',
transition: 'all 0.15s'
}}
title="重做 (Ctrl+Shift+Z / Ctrl+Y)"
onMouseEnter={(e) => {
if (canRedo) {
e.currentTarget.style.backgroundColor = '#4a4a4a';
}
}}
onMouseLeave={(e) => {
if (canRedo) {
e.currentTarget.style.backgroundColor = '#3c3c3c';
}
}}
>
<Redo size={14} />
</button>
</div>
{/* 状态指示器 */}
<div style={{
padding: '6px 12px',
backgroundColor: '#1e1e1e',
borderRadius: '6px',
fontSize: '11px',
color: '#999',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontWeight: 500,
minWidth: '70px'
}}>
<span style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor:
executionMode === 'running' ? '#16a34a' :
executionMode === 'paused' ? '#f59e0b' : '#666',
boxShadow: executionMode !== 'idle' ? `0 0 8px ${
executionMode === 'running' ? '#16a34a' :
executionMode === 'paused' ? '#f59e0b' : 'transparent'
}` : 'none',
transition: 'all 0.2s'
}} />
<span style={{
color: executionMode === 'running' ? '#16a34a' :
executionMode === 'paused' ? '#f59e0b' : '#888'
}}>
{executionMode === 'idle' ? 'Idle' :
executionMode === 'running' ? 'Running' : 'Paused'}
</span>
</div>
{onGoToRoot && (
<>
<div style={{
width: '1px',
backgroundColor: '#444',
margin: '2px 0'
}} />
<button
onClick={onGoToRoot}
style={{
padding: '6px 10px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '4px',
color: '#ccc',
cursor: 'pointer',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.15s'
}}
title="回到根节点"
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#4a4a4a'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#3c3c3c'}
>
<Home size={13} />
<span>Root</span>
</button>
</>
)}
</div>
);
};