refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 (#216)

* refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构

* feat(editor): 添加插件市场功能

* feat(editor): 重构插件市场以支持版本管理和ZIP打包

* feat(editor): 重构插件发布流程并修复React渲染警告

* fix(plugin): 修复插件发布和市场的路径不一致问题

* feat: 重构插件发布流程并添加插件删除功能

* fix(editor): 完善插件删除功能并修复多个关键问题

* fix(auth): 修复自动登录与手动登录的竞态条件问题

* feat(editor): 重构插件管理流程

* feat(editor): 支持 ZIP 文件直接发布插件

- 新增 PluginSourceParser 解析插件源
- 重构发布流程支持文件夹和 ZIP 两种方式
- 优化发布向导 UI

* feat(editor): 插件市场支持多版本安装

- 插件解压到项目 plugins 目录
- 新增 Tauri 后端安装/卸载命令
- 支持选择任意版本安装
- 修复打包逻辑,保留完整 dist 目录结构

* feat(editor): 个人中心支持多版本管理

- 合并同一插件的不同版本
- 添加版本历史展开/折叠功能
- 禁止有待审核 PR 时更新插件

* fix(editor): 修复 InspectorRegistry 服务注册

- InspectorRegistry 实现 IService 接口
- 注册到 Core.services 供插件使用

* feat(behavior-tree-editor): 完善插件注册和文件操作

- 添加文件创建模板和操作处理器
- 实现右键菜单创建行为树功能
- 修复文件读取权限问题(使用 Tauri 命令)
- 添加 BehaviorTreeEditorPanel 组件
- 修复 rollup 配置支持动态导入

* feat(plugin): 完善插件构建和发布流程

* fix(behavior-tree-editor): 完整恢复编辑器并修复 Toast 集成

* fix(behavior-tree-editor): 修复节点选中、连线跟随和文件加载问题并优化性能

* fix(behavior-tree-editor): 修复端口连接失败问题并优化连线样式

* refactor(behavior-tree-editor): 移除调试面板功能简化代码结构

* refactor(behavior-tree-editor): 清理冗余代码合并重复逻辑

* feat(behavior-tree-editor): 完善编辑器核心功能增强扩展性

* fix(lint): 修复ESLint错误确保CI通过

* refactor(behavior-tree-editor): 优化编辑器工具栏和编译器功能

* refactor(behavior-tree-editor): 清理技术债务,优化代码质量

* fix(editor-app): 修复字符串替换安全问题
This commit is contained in:
YHH
2025-11-18 14:46:51 +08:00
committed by GitHub
parent eac660b1a0
commit bce3a6e253
251 changed files with 26144 additions and 8844 deletions

View File

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

View File

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

View File

@@ -1,110 +0,0 @@
import React, { useMemo } from 'react';
import { ConnectionRenderer } from './ConnectionRenderer';
import { ConnectionViewData } from '../../../types';
import { Node } from '../../../../domain/models/Node';
import { Connection } from '../../../../domain/models/Connection';
/**
* 连线层属性
*/
interface ConnectionLayerProps {
/**
* 所有连接
*/
connections: Connection[];
/**
* 所有节点(用于查找位置)
*/
nodes: Node[];
/**
* 选中的连接
*/
selectedConnection?: { from: string; to: string } | null;
/**
* 获取端口位置的函数
*/
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
/**
* 连线点击事件
*/
onConnectionClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
/**
* 连线右键事件
*/
onConnectionContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
}
/**
* 连线层
* 管理所有连线的渲染
*/
export const ConnectionLayer: React.FC<ConnectionLayerProps> = ({
connections,
nodes,
selectedConnection,
getPortPosition,
onConnectionClick,
onConnectionContextMenu
}) => {
const nodeMap = useMemo(() => {
return new Map(nodes.map((node) => [node.id, node]));
}, [nodes]);
const connectionViewData = useMemo(() => {
return connections
.map((connection) => {
const fromNode = nodeMap.get(connection.from);
const toNode = nodeMap.get(connection.to);
if (!fromNode || !toNode) {
return null;
}
const isSelected = selectedConnection?.from === connection.from &&
selectedConnection?.to === connection.to;
const viewData: ConnectionViewData = {
connection,
isSelected
};
return { viewData, fromNode, toNode };
})
.filter((item): item is NonNullable<typeof item> => item !== null);
}, [connections, nodeMap, selectedConnection]);
if (connectionViewData.length === 0) {
return null;
}
return (
<svg
className="connection-layer"
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
overflow: 'visible'
}}
>
<g style={{ pointerEvents: 'auto' }}>
{connectionViewData.map(({ viewData, fromNode, toNode }) => (
<ConnectionRenderer
key={`${viewData.connection.from}-${viewData.connection.to}`}
connectionData={viewData}
fromNode={fromNode}
toNode={toNode}
getPortPosition={getPortPosition}
onClick={onConnectionClick}
onContextMenu={onConnectionContextMenu}
/>
))}
</g>
</svg>
);
};

View File

@@ -1,175 +0,0 @@
import React, { useMemo } from 'react';
import { ConnectionViewData } from '../../../types';
import { Node } from '../../../../domain/models/Node';
/**
* 连线渲染器属性
*/
interface ConnectionRendererProps {
/**
* 连接视图数据
*/
connectionData: ConnectionViewData;
/**
* 源节点
*/
fromNode: Node;
/**
* 目标节点
*/
toNode: Node;
/**
* 获取端口位置的函数
*/
getPortPosition: (nodeId: string, propertyName?: string, portType?: 'input' | 'output') => { x: number; y: number } | null;
/**
* 连线点击事件
*/
onClick?: (e: React.MouseEvent, fromId: string, toId: string) => void;
/**
* 连线右键事件
*/
onContextMenu?: (e: React.MouseEvent, fromId: string, toId: string) => void;
}
/**
* 连线渲染器
* 使用贝塞尔曲线渲染节点间的连接
*/
export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
connectionData,
fromNode,
toNode,
getPortPosition,
onClick,
onContextMenu
}) => {
const { connection, isSelected } = connectionData;
const pathData = useMemo(() => {
let fromPos, toPos;
if (connection.connectionType === 'property') {
// 属性连接从DOM获取实际引脚位置
fromPos = getPortPosition(connection.from);
toPos = getPortPosition(connection.to, connection.toProperty);
} else {
// 节点连接使用DOM获取端口位置
fromPos = getPortPosition(connection.from, undefined, 'output');
toPos = getPortPosition(connection.to, undefined, 'input');
}
if (!fromPos || !toPos) {
// 如果DOM还没渲染返回null
return null;
}
const x1 = fromPos.x;
const y1 = fromPos.y;
const x2 = toPos.x;
const y2 = toPos.y;
let pathD: string;
if (connection.connectionType === 'property') {
// 属性连接使用水平贝塞尔曲线
const controlX1 = x1 + (x2 - x1) * 0.5;
const controlX2 = x1 + (x2 - x1) * 0.5;
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
} else {
// 节点连接使用垂直贝塞尔曲线
const controlY = y1 + (y2 - y1) * 0.5;
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
}
return {
path: pathD,
midX: (x1 + x2) / 2,
midY: (y1 + y2) / 2
};
}, [connection, fromNode, toNode, getPortPosition]);
const color = connection.connectionType === 'property' ? '#9c27b0' : '#0e639c';
const strokeColor = isSelected ? '#FFD700' : color;
const strokeWidth = isSelected ? 4 : 2;
const markerId = `arrowhead-${connection.from}-${connection.to}`;
if (!pathData) {
// DOM还没渲染完成跳过此连接
return null;
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(e, connection.from, connection.to);
};
const handleContextMenu = (e: React.MouseEvent) => {
e.stopPropagation();
onContextMenu?.(e, connection.from, connection.to);
};
return (
<g
className="connection"
onClick={handleClick}
onContextMenu={handleContextMenu}
style={{ cursor: 'pointer' }}
data-connection-from={connection.from}
data-connection-to={connection.to}
>
{/* 透明的宽线条,用于更容易点击 */}
<path
d={pathData.path}
fill="none"
stroke="transparent"
strokeWidth={20}
/>
{/* 箭头标记定义 */}
<defs>
<marker
id={markerId}
markerWidth="10"
markerHeight="10"
refX="9"
refY="3"
orient="auto"
markerUnits="strokeWidth"
>
<polygon
points="0 0, 10 3, 0 6"
fill={strokeColor}
/>
</marker>
</defs>
{/* 实际显示的线条 */}
<path
d={pathData.path}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
markerEnd={`url(#${markerId})`}
/>
{/* 选中时显示的中点 */}
{isSelected && (
<circle
cx={pathData.midX}
cy={pathData.midY}
r="5"
fill={strokeColor}
stroke="#1a1a1a"
strokeWidth="2"
/>
)}
</g>
);
};

View File

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

View File

@@ -1,346 +0,0 @@
import React from 'react';
import {
TreePine,
Database,
AlertTriangle,
AlertCircle,
LucideIcon
} from 'lucide-react';
import { PropertyDefinition } from '@esengine/behavior-tree';
import { BehaviorTreeNode as BehaviorTreeNodeType, Connection, ROOT_NODE_ID, NodeExecutionStatus } from '../../../../stores/behaviorTreeStore';
import { BehaviorTreeExecutor } from '../../../../utils/BehaviorTreeExecutor';
import { BlackboardValue } from '../../../../domain/models/Blackboard';
type BlackboardVariables = Record<string, BlackboardValue>;
interface BehaviorTreeNodeProps {
node: BehaviorTreeNodeType;
isSelected: boolean;
isBeingDragged: boolean;
dragDelta: { dx: number; dy: number };
uncommittedNodeIds: Set<string>;
blackboardVariables: BlackboardVariables;
initialBlackboardVariables: BlackboardVariables;
isExecuting: boolean;
executionStatus?: NodeExecutionStatus;
executionOrder?: number;
connections: Connection[];
nodes: BehaviorTreeNodeType[];
executorRef: React.RefObject<BehaviorTreeExecutor | null>;
iconMap: Record<string, LucideIcon>;
draggingNodeId: string | null;
onNodeClick: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
onContextMenu: (e: React.MouseEvent, node: BehaviorTreeNodeType) => void;
onNodeMouseDown: (e: React.MouseEvent, nodeId: string) => void;
onNodeMouseUpForConnection: (e: React.MouseEvent, nodeId: string) => void;
onPortMouseDown: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
onPortMouseUp: (e: React.MouseEvent, nodeId: string, propertyName?: string) => void;
}
export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
node,
isSelected,
isBeingDragged,
dragDelta,
uncommittedNodeIds,
blackboardVariables,
initialBlackboardVariables,
isExecuting,
executionStatus,
executionOrder,
connections,
nodes,
executorRef,
iconMap,
draggingNodeId,
onNodeClick,
onContextMenu,
onNodeMouseDown,
onNodeMouseUpForConnection,
onPortMouseDown,
onPortMouseUp
}) => {
const isRoot = node.id === ROOT_NODE_ID;
const isBlackboardVariable = node.data.nodeType === 'blackboard-variable';
const posX = node.position.x + (isBeingDragged ? dragDelta.dx : 0);
const posY = node.position.y + (isBeingDragged ? dragDelta.dy : 0);
const isUncommitted = uncommittedNodeIds.has(node.id);
const nodeClasses = [
'bt-node',
isSelected && 'selected',
isRoot && 'root',
isUncommitted && 'uncommitted',
executionStatus && executionStatus !== 'idle' && executionStatus
].filter(Boolean).join(' ');
return (
<div
key={node.id}
data-node-id={node.id}
className={nodeClasses}
onClick={(e) => onNodeClick(e, node)}
onContextMenu={(e) => onContextMenu(e, node)}
onMouseDown={(e) => onNodeMouseDown(e, node.id)}
onMouseUp={(e) => onNodeMouseUpForConnection(e, node.id)}
style={{
left: posX,
top: posY,
transform: 'translate(-50%, -50%)',
cursor: isRoot ? 'default' : (draggingNodeId === node.id ? 'grabbing' : 'grab'),
transition: draggingNodeId === node.id ? 'none' : 'all 0.2s',
zIndex: isRoot ? 50 : (draggingNodeId === node.id ? 100 : (isSelected ? 10 : 1))
}}
>
{isBlackboardVariable ? (
(() => {
const varName = node.data.variableName as string;
const currentValue = blackboardVariables[varName];
const initialValue = initialBlackboardVariables[varName];
const isModified = isExecuting && JSON.stringify(currentValue) !== JSON.stringify(initialValue);
return (
<>
<div className="bt-node-header blackboard">
<Database size={16} className="bt-node-header-icon" />
<div className="bt-node-header-title">
{varName || 'Variable'}
</div>
{isModified && (
<span style={{
fontSize: '9px',
color: '#ffbb00',
backgroundColor: 'rgba(255, 187, 0, 0.2)',
padding: '2px 4px',
borderRadius: '2px',
marginLeft: '4px'
}}>
</span>
)}
</div>
<div className="bt-node-body">
<div
className="bt-node-blackboard-value"
style={{
backgroundColor: isModified ? 'rgba(255, 187, 0, 0.15)' : 'transparent',
border: isModified ? '1px solid rgba(255, 187, 0, 0.3)' : 'none',
borderRadius: '2px',
padding: '2px 4px'
}}
title={isModified ? `初始值: ${JSON.stringify(initialValue)}\n当前值: ${JSON.stringify(currentValue)}` : undefined}
>
{JSON.stringify(currentValue)}
</div>
</div>
<div
data-port="true"
data-node-id={node.id}
data-port-type="variable-output"
onMouseDown={(e) => onPortMouseDown(e, node.id, '__value__')}
onMouseUp={(e) => onPortMouseUp(e, node.id, '__value__')}
className="bt-node-port bt-node-port-variable-output"
title="Output"
/>
</>
);
})()
) : (
<>
<div className={`bt-node-header ${isRoot ? 'root' : (node.template.type || 'action')}`}>
{isRoot ? (
<TreePine size={16} className="bt-node-header-icon" />
) : (
node.template.icon && (() => {
const IconComponent = iconMap[node.template.icon];
return IconComponent ? (
<IconComponent size={16} className="bt-node-header-icon" />
) : (
<span className="bt-node-header-icon">{node.template.icon}</span>
);
})()
)}
<div className="bt-node-header-title">
<div>{isRoot ? 'ROOT' : node.template.displayName}</div>
<div className="bt-node-id" title={node.id}>
#{node.id}
</div>
</div>
{executionOrder !== undefined && (
<div
className="bt-node-execution-order"
style={{
marginLeft: 'auto',
backgroundColor: '#2196f3',
color: '#fff',
borderRadius: '50%',
width: '20px',
height: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
fontWeight: 'bold',
flexShrink: 0
}}
title={`执行顺序: ${executionOrder}`}
>
{executionOrder}
</div>
)}
{!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
<div
className="bt-node-missing-executor-warning"
style={{
marginLeft: executionOrder !== undefined ? '4px' : 'auto',
display: 'flex',
alignItems: 'center',
cursor: 'help',
pointerEvents: 'auto',
position: 'relative'
}}
onClick={(e) => e.stopPropagation()}
>
<AlertCircle
size={14}
style={{
color: '#f44336',
flexShrink: 0
}}
/>
<div className="bt-node-missing-executor-tooltip">
"{node.template.className}"
</div>
</div>
)}
{isUncommitted && (
<div
className="bt-node-uncommitted-warning"
style={{
marginLeft: (executionOrder !== undefined || (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className))) ? '4px' : 'auto',
display: 'flex',
alignItems: 'center',
cursor: 'help',
pointerEvents: 'auto',
position: 'relative'
}}
onClick={(e) => e.stopPropagation()}
>
<AlertTriangle
size={14}
style={{
color: '#ff5722',
flexShrink: 0
}}
/>
<div className="bt-node-uncommitted-tooltip">
</div>
</div>
)}
{!isRoot && !isUncommitted && node.template.type === 'composite' &&
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) &&
!nodes.some((n) =>
connections.some((c) => c.from === node.id && c.to === n.id)
) && (
<div
className="bt-node-empty-warning-container"
style={{
marginLeft: (executionOrder !== undefined || (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) || isUncommitted) ? '4px' : 'auto',
display: 'flex',
alignItems: 'center',
cursor: 'help',
pointerEvents: 'auto',
position: 'relative'
}}
onClick={(e) => e.stopPropagation()}
>
<AlertTriangle
size={14}
style={{
color: '#ff9800',
flexShrink: 0
}}
/>
<div className="bt-node-empty-warning-tooltip">
</div>
</div>
)}
</div>
<div className="bt-node-body">
{!isRoot && (
<div className="bt-node-category">
{node.template.category}
</div>
)}
{node.template.properties.length > 0 && (
<div className="bt-node-properties">
{node.template.properties.map((prop: PropertyDefinition, idx: number) => {
const hasConnection = connections.some(
(conn: Connection) => conn.toProperty === prop.name && conn.to === node.id
);
const propValue = node.data[prop.name];
return (
<div key={idx} className="bt-node-property">
<div
data-port="true"
data-node-id={node.id}
data-property={prop.name}
data-port-type="property-input"
onMouseDown={(e) => onPortMouseDown(e, node.id, prop.name)}
onMouseUp={(e) => onPortMouseUp(e, node.id, prop.name)}
className={`bt-node-port bt-node-port-property ${hasConnection ? 'connected' : ''}`}
title={prop.description || prop.name}
/>
<span
className="bt-node-property-label"
title={prop.description}
>
{prop.name}:
</span>
{propValue !== undefined && (
<span className="bt-node-property-value">
{String(propValue)}
</span>
)}
</div>
);
})}
</div>
)}
</div>
{!isRoot && (
<div
data-port="true"
data-node-id={node.id}
data-port-type="node-input"
onMouseDown={(e) => onPortMouseDown(e, node.id)}
onMouseUp={(e) => onPortMouseUp(e, node.id)}
className="bt-node-port bt-node-port-input"
title="Input"
/>
)}
{(isRoot || node.template.type === 'composite' || node.template.type === 'decorator') &&
(node.template.requiresChildren === undefined || node.template.requiresChildren === true) && (
<div
data-port="true"
data-node-id={node.id}
data-port-type="node-output"
onMouseDown={(e) => onPortMouseDown(e, node.id)}
onMouseUp={(e) => onPortMouseUp(e, node.id)}
className="bt-node-port bt-node-port-output"
title="Output"
/>
)}
</>
)}
</div>
);
};

View File

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

View File

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

View File

@@ -1,283 +0,0 @@
.behavior-tree-editor-panel {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: #1e1e1e;
}
/* 全屏模式 */
.behavior-tree-editor-panel.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: #1e1e1e;
}
/* 全屏模式下的工具栏 - 确保不透明 */
.behavior-tree-editor-panel.fullscreen .behavior-tree-editor-toolbar {
background: #252526;
opacity: 1;
position: relative;
z-index: 10000;
}
/* 工具栏 */
.behavior-tree-editor-toolbar {
display: flex;
align-items: center;
padding: 8px 12px;
background: #252526;
border-bottom: 1px solid #3e3e42;
gap: 8px;
flex-wrap: wrap;
}
.behavior-tree-editor-toolbar .toolbar-section {
display: flex;
align-items: center;
gap: 4px;
}
.behavior-tree-editor-toolbar .toolbar-divider {
width: 1px;
height: 24px;
background: #3e3e42;
margin: 0 4px;
}
.behavior-tree-editor-toolbar .toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
color: #cccccc;
transition: all 0.2s;
}
.behavior-tree-editor-toolbar .toolbar-btn:hover:not(:disabled) {
background: #2a2d2e;
border-color: #3e3e42;
}
.behavior-tree-editor-toolbar .toolbar-btn:active:not(:disabled) {
background: #37373d;
}
.behavior-tree-editor-toolbar .toolbar-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* 执行控制按钮颜色 */
.behavior-tree-editor-toolbar .toolbar-btn.btn-play {
color: #4caf50;
}
.behavior-tree-editor-toolbar .toolbar-btn.btn-play:hover:not(:disabled) {
background: rgba(76, 175, 80, 0.15);
border-color: rgba(76, 175, 80, 0.3);
}
.behavior-tree-editor-toolbar .toolbar-btn.btn-pause {
color: #ff9800;
}
.behavior-tree-editor-toolbar .toolbar-btn.btn-pause:hover:not(:disabled) {
background: rgba(255, 152, 0, 0.15);
border-color: rgba(255, 152, 0, 0.3);
}
.behavior-tree-editor-toolbar .toolbar-btn.btn-stop {
color: #f44336;
}
.behavior-tree-editor-toolbar .toolbar-btn.btn-stop:hover:not(:disabled) {
background: rgba(244, 67, 54, 0.15);
border-color: rgba(244, 67, 54, 0.3);
}
.behavior-tree-editor-toolbar .toolbar-btn.btn-step {
color: #2196f3;
}
.behavior-tree-editor-toolbar .toolbar-btn.btn-step:hover:not(:disabled) {
background: rgba(33, 150, 243, 0.15);
border-color: rgba(33, 150, 243, 0.3);
}
/* 速率控制 */
.speed-control {
display: flex;
align-items: center;
gap: 8px;
margin-left: 8px;
}
.speed-control .speed-label {
font-size: 12px;
color: #999;
}
.speed-control .speed-slider {
width: 100px;
height: 6px;
background: #3c3c3c;
border-radius: 3px;
outline: none;
appearance: none;
-webkit-appearance: none;
}
.speed-control .speed-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: #0e639c;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 0 2px rgba(14, 99, 156, 0.3);
transition: all 0.2s;
}
.speed-control .speed-slider::-webkit-slider-thumb:hover {
background: #1177bb;
box-shadow: 0 0 0 4px rgba(14, 99, 156, 0.3);
}
.speed-control .speed-slider::-moz-range-thumb {
width: 16px;
height: 16px;
background: #0e639c;
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 0 0 2px rgba(14, 99, 156, 0.3);
transition: all 0.2s;
}
.speed-control .speed-slider::-moz-range-thumb:hover {
background: #1177bb;
box-shadow: 0 0 0 4px rgba(14, 99, 156, 0.3);
}
.speed-control .speed-value {
font-size: 12px;
color: #ccc;
min-width: 35px;
text-align: right;
}
/* 文件信息 */
.behavior-tree-editor-toolbar .file-info {
flex: 1;
justify-content: flex-end;
}
.behavior-tree-editor-toolbar .file-name {
font-size: 13px;
color: #9d9d9d;
font-weight: 500;
}
/* 主内容区 */
.behavior-tree-editor-content {
flex: 1;
overflow: hidden;
position: relative;
}
.editor-canvas-area {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
/* 黑板侧边栏 - 浮动面板 */
.blackboard-sidebar {
position: absolute;
right: 16px;
top: 16px;
bottom: 16px;
width: 350px;
display: flex;
flex-direction: column;
background: #252526;
border: 1px solid #3e3e42;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.05);
z-index: 100;
}
.blackboard-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: #2d2d2d;
border-bottom: 1px solid #3e3e42;
border-radius: 8px 8px 0 0;
font-weight: 500;
font-size: 13px;
color: #cccccc;
}
.blackboard-header .close-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
color: #9d9d9d;
transition: all 0.2s;
}
.blackboard-header .close-btn:hover {
background: #2a2d2e;
border-color: #3e3e42;
color: #cccccc;
}
.blackboard-content {
flex: 1;
overflow: hidden;
}
/* 黑板切换按钮 */
.blackboard-toggle-btn {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 48px;
background: #252526;
border: 1px solid #3e3e42;
border-radius: 8px 0 0 8px;
cursor: pointer;
color: #9d9d9d;
transition: all 0.2s;
z-index: 99;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2);
}
.blackboard-toggle-btn:hover {
background: #2a2d2e;
color: #cccccc;
width: 36px;
}

View File

@@ -1,771 +0,0 @@
import { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Save, FolderOpen, Download, Play, Pause, Square, SkipForward, Clipboard, ChevronRight, ChevronLeft, Copy, Home, Maximize2, Minimize2 } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { open, message } from '@tauri-apps/plugin-dialog';
import { Core } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { BehaviorTreeEditor } from '../../../../components/BehaviorTreeEditor';
import { BehaviorTreeBlackboard } from '../../../../components/BehaviorTreeBlackboard';
import { ExportRuntimeDialog, type ExportOptions } from '../../../../components/ExportRuntimeDialog';
import { BehaviorTreeNameDialog } from '../../../../components/BehaviorTreeNameDialog';
import { useToast } from '../../../../components/Toast';
import { useBehaviorTreeStore, ROOT_NODE_ID } from '../../../../stores/behaviorTreeStore';
import { EditorFormatConverter, BehaviorTreeAssetSerializer, GlobalBlackboardService, type BlackboardValueType } from '@esengine/behavior-tree';
import { createLogger } from '@esengine/ecs-framework';
import { LocalBlackboardTypeGenerator } from '../../../../generators/LocalBlackboardTypeGenerator';
import { GlobalBlackboardTypeGenerator } from '../../../../generators/GlobalBlackboardTypeGenerator';
import { useExecutionController } from '../../../hooks/useExecutionController';
import { behaviorTreeFileService } from '../../../../services/BehaviorTreeFileService';
import './BehaviorTreeEditorPanel.css';
const logger = createLogger('BehaviorTreeEditorPanel');
interface BehaviorTreeEditorPanelProps {
projectPath?: string | null;
}
export const BehaviorTreeEditorPanel: React.FC<BehaviorTreeEditorPanelProps> = ({ projectPath: propProjectPath }) => {
const { t } = useTranslation();
const { showToast } = useToast();
const messageHub = Core.services.resolve(MessageHub);
const {
isOpen,
pendingFilePath,
setPendingFilePath,
nodes,
connections,
exportToJSON,
exportToRuntimeAsset,
blackboardVariables,
setBlackboardVariables,
updateBlackboardVariable,
initialBlackboardVariables,
setInitialBlackboardVariables,
isExecuting,
setIsExecuting,
saveNodesDataSnapshot,
restoreNodesData,
resetView
} = useBehaviorTreeStore();
const [currentFilePath, setCurrentFilePath] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [projectPath, setProjectPath] = useState<string>('');
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [availableBTreeFiles, setAvailableBTreeFiles] = useState<string[]>([]);
const [isBlackboardOpen, setIsBlackboardOpen] = useState(true);
const [globalVariables, setGlobalVariables] = useState<Record<string, any>>({});
const [hasUnsavedGlobalChanges, setHasUnsavedGlobalChanges] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const isInitialMount = useRef(true);
const initialStateSnapshot = useRef<{ nodes: number; variables: number }>({ nodes: 0, variables: 0 });
const processingFileRef = useRef<string | null>(null);
const {
executionMode,
executionSpeed,
handlePlay,
handlePause,
handleStop,
handleStep,
handleSpeedChange
} = useExecutionController({
rootNodeId: ROOT_NODE_ID,
projectPath: projectPath || '',
blackboardVariables,
nodes,
connections,
initialBlackboardVariables,
onBlackboardUpdate: setBlackboardVariables,
onInitialBlackboardSave: setInitialBlackboardVariables,
onExecutingChange: setIsExecuting,
onSaveNodesDataSnapshot: saveNodesDataSnapshot,
onRestoreNodesData: restoreNodesData
});
useEffect(() => {
const initProject = async () => {
if (propProjectPath) {
setProjectPath(propProjectPath);
localStorage.setItem('ecs-project-path', propProjectPath);
await loadGlobalBlackboard(propProjectPath);
} else {
const savedPath = localStorage.getItem('ecs-project-path');
if (savedPath) {
setProjectPath(savedPath);
await loadGlobalBlackboard(savedPath);
}
}
};
initProject();
}, [propProjectPath]);
useEffect(() => {
const loadAvailableFiles = async () => {
if (projectPath) {
try {
const files = await invoke<string[]>('scan_behavior_trees', { projectPath });
setAvailableBTreeFiles(files);
} catch (error) {
logger.error('加载行为树文件列表失败', error);
setAvailableBTreeFiles([]);
}
} else {
setAvailableBTreeFiles([]);
}
};
loadAvailableFiles();
}, [projectPath]);
useEffect(() => {
const updateGlobalVariables = () => {
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
const allVars = globalBlackboard.getAllVariables();
const varsObject: Record<string, any> = {};
allVars.forEach((v) => {
varsObject[v.name] = v.value;
});
setGlobalVariables(varsObject);
};
updateGlobalVariables();
const interval = setInterval(updateGlobalVariables, 100);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
const currentNodes = nodes.length;
const currentVariables = Object.keys(blackboardVariables).length;
if (currentNodes !== initialStateSnapshot.current.nodes ||
currentVariables !== initialStateSnapshot.current.variables) {
setHasUnsavedChanges(true);
}
}, [nodes, blackboardVariables]);
const loadFile = useCallback(async (filePath: string) => {
const result = await behaviorTreeFileService.loadFile(filePath);
if (result.success && result.fileName) {
setCurrentFilePath(filePath);
setHasUnsavedChanges(false);
isInitialMount.current = true;
initialStateSnapshot.current = { nodes: 0, variables: 0 };
showToast(`已打开 ${result.fileName}`, 'success');
} else if (result.error) {
showToast(`加载失败: ${result.error}`, 'error');
}
}, [showToast]);
// 使用 useLayoutEffect 处理 pendingFilePath同步执行DOM 更新前)
// 这是文件加载的唯一入口,避免重复
useLayoutEffect(() => {
if (!pendingFilePath) return;
// 防止 React StrictMode 导致的重复执行
if (processingFileRef.current === pendingFilePath) {
return;
}
processingFileRef.current = pendingFilePath;
loadFile(pendingFilePath).then(() => {
setPendingFilePath(null);
processingFileRef.current = null;
});
}, [pendingFilePath, loadFile, setPendingFilePath]);
const loadGlobalBlackboard = async (path: string) => {
try {
const json = await invoke<string>('read_global_blackboard', { projectPath: path });
const config = JSON.parse(json);
Core.services.resolve(GlobalBlackboardService).importConfig(config);
const allVars = Core.services.resolve(GlobalBlackboardService).getAllVariables();
const varsObject: Record<string, any> = {};
allVars.forEach((v) => {
varsObject[v.name] = v.value;
});
setGlobalVariables(varsObject);
setHasUnsavedGlobalChanges(false);
logger.info('全局黑板配置已加载');
} catch (error) {
logger.error('加载全局黑板配置失败', error);
}
};
const saveGlobalBlackboard = async () => {
if (!projectPath) {
logger.error('未设置项目路径,无法保存全局黑板配置');
return;
}
try {
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
const json = globalBlackboard.toJSON();
await invoke('write_global_blackboard', { projectPath, content: json });
setHasUnsavedGlobalChanges(false);
logger.info('全局黑板配置已保存到', `${projectPath}/.ecs/global-blackboard.json`);
showToast('全局黑板已保存', 'success');
} catch (error) {
logger.error('保存全局黑板配置失败', error);
showToast('保存全局黑板失败', 'error');
}
};
const handleOpen = async () => {
try {
const selected = await open({
multiple: false,
filters: [{
name: 'Behavior Tree',
extensions: ['btree']
}]
});
if (selected) {
const result = await behaviorTreeFileService.loadFile(selected as string);
if (result.success && result.fileName) {
setCurrentFilePath(selected as string);
setHasUnsavedChanges(false);
isInitialMount.current = true;
initialStateSnapshot.current = { nodes: 0, variables: 0 };
showToast(`已打开 ${result.fileName}`, 'success');
} else if (result.error) {
showToast(`加载失败: ${result.error}`, 'error');
}
}
} catch (error) {
logger.error('加载失败', error);
showToast(`加载失败: ${error}`, 'error');
}
};
const handleSave = async () => {
try {
if (isExecuting) {
const confirmed = window.confirm(
'行为树正在运行中。保存将使用设计时的初始值,运行时修改的黑板变量不会被保存。\n\n是否继续保存'
);
if (!confirmed) {
return;
}
}
const saveFilePath = currentFilePath;
if (!saveFilePath) {
if (!projectPath) {
logger.error('未设置项目路径,无法保存行为树');
await message('请先打开项目', { title: '错误', kind: 'error' });
return;
}
setIsSaveDialogOpen(true);
return;
}
await saveToFile(saveFilePath);
} catch (error) {
logger.error('保存失败', error);
}
};
const saveToFile = async (filePath: string) => {
try {
const json = exportToJSON({ name: 'behavior-tree', description: '' });
await invoke('write_behavior_tree_file', { filePath, content: json });
logger.info('行为树已保存', filePath);
setCurrentFilePath(filePath);
setHasUnsavedChanges(false);
isInitialMount.current = true;
const fileName = filePath.split(/[\\/]/).pop()?.replace('.btree', '') || '行为树';
showToast(`${fileName} 已保存`, 'success');
} catch (error) {
logger.error('保存失败', error);
showToast(`保存失败: ${error}`, 'error');
throw error;
}
};
const handleSaveDialogConfirm = async (name: string) => {
setIsSaveDialogOpen(false);
try {
const filePath = `${projectPath}/.ecs/behaviors/${name}.btree`;
await saveToFile(filePath);
const files = await invoke<string[]>('scan_behavior_trees', { projectPath });
setAvailableBTreeFiles(files);
} catch (error) {
logger.error('保存失败', error);
}
};
const handleExportRuntime = () => {
setIsExportDialogOpen(true);
};
const handleDoExport = async (options: ExportOptions) => {
if (options.mode === 'workspace') {
await handleExportWorkspace(options);
} else {
const fileName = options.selectedFiles[0];
if (!fileName) {
logger.error('没有可导出的文件');
return;
}
const format = options.fileFormats.get(fileName) || 'binary';
await handleExportSingle(fileName, format, options.assetOutputPath, options.typeOutputPath);
}
};
const handleExportSingle = async (fileName: string, format: 'json' | 'binary', outputPath: string, typeOutputPath: string) => {
try {
const extension = format === 'binary' ? 'bin' : 'json';
const filePath = `${outputPath}/${fileName}.btree.${extension}`;
const data = exportToRuntimeAsset(
{ name: fileName, description: 'Runtime behavior tree asset' },
format
);
await invoke('create_directory', { path: outputPath });
if (format === 'binary') {
await invoke('write_binary_file', { filePath, content: Array.from(data as Uint8Array) });
} else {
await invoke('write_file_content', { path: filePath, content: data as string });
}
logger.info(`运行时资产已导出 (${format})`, filePath);
await generateTypeScriptTypes(fileName, typeOutputPath);
showToast(`${fileName} 导出成功`, 'success');
} catch (error) {
logger.error('导出失败', error);
showToast(`导出失败: ${error}`, 'error');
}
};
const generateTypeScriptTypes = async (assetId: string, outputPath: string): Promise<void> => {
try {
const sourceFilePath = `${projectPath}/.ecs/behaviors/${assetId}.btree`;
const editorJson = await invoke<string>('read_file_content', { path: sourceFilePath });
const editorFormat = JSON.parse(editorJson);
const blackboard = editorFormat.blackboard || {};
const tsCode = LocalBlackboardTypeGenerator.generate(blackboard, {
behaviorTreeName: assetId,
includeConstants: true,
includeDefaults: true,
includeHelpers: true
});
const tsFilePath = `${outputPath}/${assetId}.ts`;
await invoke('create_directory', { path: outputPath });
await invoke('write_file_content', {
path: tsFilePath,
content: tsCode
});
logger.info(`TypeScript 类型定义已生成: ${assetId}.ts`);
} catch (error) {
logger.error(`生成 TypeScript 类型定义失败: ${assetId}`, error);
}
};
const handleCopyBehaviorTree = () => {
const buildNodeTree = (nodeId: string, depth: number = 0): string => {
const node = nodes.find((n) => n.id === nodeId);
if (!node) return '';
const indent = ' '.repeat(depth);
const childrenText = node.children.length > 0
? `\n${node.children.map((childId) => buildNodeTree(childId, depth + 1)).join('\n')}`
: '';
const propertiesText = Object.keys(node.data).length > 0
? ` [${Object.entries(node.data).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}]`
: '';
return `${indent}- ${node.template.displayName} (${node.template.type})${propertiesText}${childrenText}`;
};
const rootNode = nodes.find((n) => n.id === ROOT_NODE_ID);
if (!rootNode) {
showToast('未找到根节点', 'error');
return;
}
const treeStructure = `
行为树结构
==========
文件: ${currentFilePath || '未保存'}
节点总数: ${nodes.length}
连接总数: ${connections.length}
节点树:
${buildNodeTree(ROOT_NODE_ID)}
黑板变量 (${Object.keys(blackboardVariables).length}个):
${Object.entries(blackboardVariables).map(([key, value]) => ` - ${key}: ${JSON.stringify(value)}`).join('\n') || ' 无'}
全部节点详情:
${nodes.filter((n) => n.id !== ROOT_NODE_ID).map((node) => {
const incoming = connections.filter((c) => c.to === node.id);
const outgoing = connections.filter((c) => c.from === node.id);
return `
[${node.template.displayName}]
类型: ${node.template.type}
分类: ${node.template.category}
类名: ${node.template.className || '无'}
ID: ${node.id}
子节点: ${node.children.length}
输入连接: ${incoming.length}${incoming.length > 0 ? '\n ' + incoming.map((c) => {
const fromNode = nodes.find((n) => n.id === c.from);
return `${fromNode?.template.displayName || '未知'}`;
}).join('\n ') : ''}
输出连接: ${outgoing.length}${outgoing.length > 0 ? '\n ' + outgoing.map((c) => {
const toNode = nodes.find((n) => n.id === c.to);
return `${toNode?.template.displayName || '未知'}`;
}).join('\n ') : ''}
属性: ${JSON.stringify(node.data, null, 4)}`;
}).join('\n')}
`.trim();
navigator.clipboard.writeText(treeStructure).then(() => {
showToast('行为树结构已复制到剪贴板', 'success');
}).catch(() => {
const textarea = document.createElement('textarea');
textarea.value = treeStructure;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('行为树结构已复制到剪贴板', 'success');
});
};
const handleExportWorkspace = async (options: ExportOptions) => {
if (!projectPath) {
logger.error('未设置项目路径');
return;
}
try {
const assetOutputDir = options.assetOutputPath;
if (options.selectedFiles.length === 0) {
logger.warn('没有选择要导出的文件');
return;
}
logger.info(`开始导出 ${options.selectedFiles.length} 个文件...`);
for (const assetId of options.selectedFiles) {
try {
const format = options.fileFormats.get(assetId) || 'binary';
const extension = format === 'binary' ? 'bin' : 'json';
const sourceFilePath = `${projectPath}/.ecs/behaviors/${assetId}.btree`;
const editorJson = await invoke<string>('read_file_content', { path: sourceFilePath });
const editorFormat = JSON.parse(editorJson);
const asset = EditorFormatConverter.toAsset(editorFormat, {
name: assetId,
description: editorFormat.metadata?.description || ''
});
const data = BehaviorTreeAssetSerializer.serialize(asset, {
format,
pretty: format === 'json',
validate: true
});
const outputFilePath = `${assetOutputDir}/${assetId}.btree.${extension}`;
const outputDir2 = outputFilePath.substring(0, outputFilePath.lastIndexOf('/'));
await invoke('create_directory', { path: outputDir2 });
if (format === 'binary') {
await invoke('write_binary_file', {
filePath: outputFilePath,
content: Array.from(data as Uint8Array)
});
} else {
await invoke('write_file_content', {
path: outputFilePath,
content: data as string
});
}
logger.info(`导出成功: ${assetId} (${format})`);
await generateTypeScriptTypes(assetId, options.typeOutputPath);
} catch (error) {
logger.error(`导出失败: ${assetId}`, error);
}
}
try {
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
const config = globalBlackboard.exportConfig();
const tsCode = GlobalBlackboardTypeGenerator.generate(config);
const globalTsFilePath = `${options.typeOutputPath}/GlobalBlackboard.ts`;
await invoke('write_file_content', {
path: globalTsFilePath,
content: tsCode
});
logger.info('全局变量类型定义已生成:', globalTsFilePath);
} catch (error) {
logger.error('导出全局变量类型定义失败', error);
}
logger.info(`工作区导出完成: ${assetOutputDir}`);
showToast('工作区导出成功', 'success');
} catch (error) {
logger.error('工作区导出失败', error);
showToast(`工作区导出失败: ${error}`, 'error');
}
};
const handleVariableChange = (key: string, value: any) => {
updateBlackboardVariable(key, value);
};
const handleVariableAdd = (key: string, value: any) => {
updateBlackboardVariable(key, value);
};
const handleVariableDelete = (key: string) => {
const newVars = { ...blackboardVariables };
delete newVars[key];
setBlackboardVariables(newVars);
};
const handleVariableRename = (oldKey: string, newKey: string) => {
if (oldKey === newKey) return;
const newVars = { ...blackboardVariables };
const value = newVars[oldKey];
delete newVars[oldKey];
newVars[newKey] = value;
setBlackboardVariables(newVars);
};
const handleGlobalVariableChange = (key: string, value: any) => {
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
globalBlackboard.setValue(key, value, true);
const allVars = globalBlackboard.getAllVariables();
const varsObject: Record<string, any> = {};
allVars.forEach((v) => {
varsObject[v.name] = v.value;
});
setGlobalVariables(varsObject);
setHasUnsavedGlobalChanges(true);
};
const handleGlobalVariableAdd = (key: string, value: any, type: BlackboardValueType) => {
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
globalBlackboard.defineVariable(key, type, value);
const allVars = globalBlackboard.getAllVariables();
const varsObject: Record<string, any> = {};
allVars.forEach((v) => {
varsObject[v.name] = v.value;
});
setGlobalVariables(varsObject);
setHasUnsavedGlobalChanges(true);
};
const handleGlobalVariableDelete = (key: string) => {
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
globalBlackboard.removeVariable(key);
const allVars = globalBlackboard.getAllVariables();
const varsObject: Record<string, any> = {};
allVars.forEach((v) => {
varsObject[v.name] = v.value;
});
setGlobalVariables(varsObject);
setHasUnsavedGlobalChanges(true);
};
const toggleFullscreen = () => {
const newFullscreenState = !isFullscreen;
setIsFullscreen(newFullscreenState);
// 通知主界面切换全屏状态
messageHub?.publish('editor:fullscreen', { fullscreen: newFullscreenState });
};
if (!isOpen) {
return null;
}
return (
<div className={`behavior-tree-editor-panel ${isFullscreen ? 'fullscreen' : ''}`}>
<div className="behavior-tree-editor-toolbar">
{/* 文件操作 */}
<div className="toolbar-section">
<button onClick={handleOpen} className="toolbar-btn" title="打开">
<FolderOpen size={16} />
</button>
<button onClick={handleSave} className="toolbar-btn" title="保存">
<Save size={16} />
</button>
<button onClick={handleExportRuntime} className="toolbar-btn" title="导出运行时资产">
<Download size={16} />
</button>
</div>
<div className="toolbar-divider" />
{/* 执行控制 */}
<div className="toolbar-section">
{executionMode === 'idle' || executionMode === 'step' ? (
<button onClick={handlePlay} className="toolbar-btn btn-play" title="开始执行">
<Play size={16} />
</button>
) : executionMode === 'paused' ? (
<button onClick={handlePlay} className="toolbar-btn btn-play" title="继续">
<Play size={16} />
</button>
) : (
<button onClick={handlePause} className="toolbar-btn btn-pause" title="暂停">
<Pause size={16} />
</button>
)}
<button onClick={handleStop} className="toolbar-btn btn-stop" title="停止" disabled={executionMode === 'idle'}>
<Square size={16} />
</button>
<button onClick={handleStep} className="toolbar-btn btn-step" title="单步执行" disabled={executionMode === 'running'}>
<SkipForward size={16} />
</button>
<div className="speed-control">
<span className="speed-label">:</span>
<input
type="range"
min="0.1"
max="5"
step="0.1"
value={executionSpeed}
onChange={(e) => handleSpeedChange(parseFloat(e.target.value))}
className="speed-slider"
title={`执行速率: ${executionSpeed.toFixed(1)}x`}
/>
<span className="speed-value">{executionSpeed.toFixed(1)}x</span>
</div>
</div>
<div className="toolbar-divider" />
{/* 视图控制 */}
<div className="toolbar-section">
<button onClick={resetView} className="toolbar-btn" title="重置视图">
<Home size={16} />
</button>
<button onClick={toggleFullscreen} className="toolbar-btn" title={isFullscreen ? '退出全屏' : '全屏编辑'}>
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
</button>
<button onClick={() => setIsBlackboardOpen(!isBlackboardOpen)} className="toolbar-btn" title="黑板">
<Clipboard size={16} />
</button>
<button onClick={handleCopyBehaviorTree} className="toolbar-btn" title="复制整个行为树结构">
<Copy size={16} />
</button>
</div>
{/* 文件名 */}
<div className="toolbar-section file-info">
<span className="file-name">
{currentFilePath
? `${currentFilePath.split(/[\\/]/).pop()?.replace('.btree', '')}${hasUnsavedChanges ? ' *' : ''}`
: t('behaviorTree.title')
}
</span>
</div>
</div>
<div className="behavior-tree-editor-content">
<div className="editor-canvas-area">
<BehaviorTreeEditor
onNodeSelect={(node) => {
messageHub?.publish('behavior-tree:node-selected', { node });
}}
onNodeCreate={(_template, _position) => {
// Node created
}}
blackboardVariables={blackboardVariables}
projectPath={projectPath || propProjectPath || null}
showToolbar={false}
/>
</div>
{isBlackboardOpen && (
<div className="blackboard-sidebar">
<div className="blackboard-header">
<span>{t('behaviorTree.blackboard')}</span>
<button onClick={() => setIsBlackboardOpen(false)} className="close-btn">
<ChevronRight size={16} />
</button>
</div>
<div className="blackboard-content">
<BehaviorTreeBlackboard
variables={blackboardVariables}
initialVariables={isExecuting ? initialBlackboardVariables : undefined}
globalVariables={globalVariables}
onVariableChange={handleVariableChange}
onVariableAdd={handleVariableAdd}
onVariableDelete={handleVariableDelete}
onVariableRename={handleVariableRename}
onGlobalVariableChange={handleGlobalVariableChange}
onGlobalVariableAdd={handleGlobalVariableAdd}
onGlobalVariableDelete={handleGlobalVariableDelete}
projectPath={projectPath}
hasUnsavedGlobalChanges={hasUnsavedGlobalChanges}
onSaveGlobal={saveGlobalBlackboard}
/>
</div>
</div>
)}
{!isBlackboardOpen && (
<button onClick={() => setIsBlackboardOpen(true)} className="blackboard-toggle-btn" title="显示黑板">
<ChevronLeft size={16} />
</button>
)}
</div>
<ExportRuntimeDialog
isOpen={isExportDialogOpen}
onClose={() => setIsExportDialogOpen(false)}
onExport={handleDoExport}
hasProject={!!projectPath}
availableFiles={availableBTreeFiles}
currentFileName={currentFilePath ? currentFilePath.split(/[\\/]/).pop()?.replace('.btree', '') : undefined}
projectPath={projectPath}
/>
<BehaviorTreeNameDialog
isOpen={isSaveDialogOpen}
onConfirm={handleSaveDialogConfirm}
onCancel={() => setIsSaveDialogOpen(false)}
/>
</div>
);
};

View File

@@ -1,8 +0,0 @@
.behavior-tree-node-palette-panel {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
overflow: hidden;
}

View File

@@ -1,18 +0,0 @@
import { Core } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { BehaviorTreeNodePalette } from '../../../../components/BehaviorTreeNodePalette';
import './BehaviorTreeNodePalettePanel.css';
export const BehaviorTreeNodePalettePanel: React.FC = () => {
const messageHub = Core.services.resolve(MessageHub);
return (
<div className="behavior-tree-node-palette-panel">
<BehaviorTreeNodePalette
onNodeSelect={(template) => {
messageHub?.publish('behavior-tree:node-palette-selected', { template });
}}
/>
</div>
);
};

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,381 +0,0 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings, Clipboard } from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { BehaviorTreeNodeProperties } from '../../../../components/BehaviorTreeNodeProperties';
import { BehaviorTreeBlackboard } from '../../../../components/BehaviorTreeBlackboard';
import { GlobalBlackboardService, type BlackboardValueType, type NodeTemplate } from '@esengine/behavior-tree';
import { useBehaviorTreeStore, type Connection } from '../../../../stores/behaviorTreeStore';
import { invoke } from '@tauri-apps/api/core';
import { useToast } from '../../../../components/Toast';
import { createLogger } from '@esengine/ecs-framework';
import './BehaviorTreePropertiesPanel.css';
const logger = createLogger('BehaviorTreePropertiesPanel');
interface BehaviorTreePropertiesPanelProps {
projectPath?: string | null;
}
export const BehaviorTreePropertiesPanel: React.FC<BehaviorTreePropertiesPanelProps> = ({ projectPath: propProjectPath }) => {
const { t } = useTranslation();
const { showToast } = useToast();
const messageHub = Core.services.resolve(MessageHub);
const {
nodes,
connections,
updateNodes,
blackboardVariables,
setBlackboardVariables,
updateBlackboardVariable,
initialBlackboardVariables,
isExecuting,
removeConnections
} = useBehaviorTreeStore();
const [selectedNode, setSelectedNode] = useState<{
id: string;
template: NodeTemplate;
data: Record<string, any>;
} | undefined>();
const [activeTab, setActiveTab] = useState<'properties' | 'blackboard'>('blackboard');
const [projectPath, setProjectPath] = useState<string>('');
const [globalVariables, setGlobalVariables] = useState<Record<string, any>>({});
const [hasUnsavedGlobalChanges, setHasUnsavedGlobalChanges] = useState(false);
useEffect(() => {
const initProject = async () => {
if (propProjectPath) {
setProjectPath(propProjectPath);
localStorage.setItem('ecs-project-path', propProjectPath);
await loadGlobalBlackboard(propProjectPath);
} else {
const savedPath = localStorage.getItem('ecs-project-path');
if (savedPath) {
setProjectPath(savedPath);
await loadGlobalBlackboard(savedPath);
}
}
};
initProject();
}, [propProjectPath]);
useEffect(() => {
const updateGlobalVariables = () => {
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
const allVars = globalBlackboard.getAllVariables();
const varsObject: Record<string, any> = {};
allVars.forEach((v) => {
varsObject[v.name] = v.value;
});
setGlobalVariables(varsObject);
};
updateGlobalVariables();
const interval = setInterval(updateGlobalVariables, 100);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const handleNodeSelected = (data: any) => {
if (data.node) {
let template = data.node.template;
let nodeData = data.node.data;
if (data.node.data.nodeType === 'blackboard-variable') {
const varName = (data.node.data.variableName as string) || '';
const varValue = blackboardVariables[varName];
const varType = typeof varValue === 'number' ? 'number' :
typeof varValue === 'boolean' ? 'boolean' : 'string';
nodeData = {
...data.node.data,
__blackboardValue: varValue
};
template = {
...data.node.template,
properties: [
{
name: 'variableName',
label: t('behaviorTree.variableName'),
type: 'variable',
defaultValue: varName,
description: t('behaviorTree.variableName'),
required: true
},
{
name: '__blackboardValue',
label: t('behaviorTree.currentValue'),
type: varType,
defaultValue: varValue,
description: t('behaviorTree.currentValue')
}
]
};
}
setSelectedNode({
id: data.node.id,
template,
data: nodeData
});
setActiveTab('properties');
}
};
const unsubscribe = messageHub?.subscribe('behavior-tree:node-selected', handleNodeSelected);
return () => unsubscribe?.();
}, [messageHub, blackboardVariables, t]);
useEffect(() => {
if (selectedNode && selectedNode.id) {
const nodeStillExists = nodes.some((node: any) => node.id === selectedNode.id);
if (!nodeStillExists) {
setSelectedNode(undefined);
}
}
}, [nodes, selectedNode]);
const loadGlobalBlackboard = async (path: string) => {
try {
const json = await invoke<string>('read_global_blackboard', { projectPath: path });
const config = JSON.parse(json);
Core.services.resolve(GlobalBlackboardService).importConfig(config);
const allVars = Core.services.resolve(GlobalBlackboardService).getAllVariables();
const varsObject: Record<string, any> = {};
allVars.forEach((v) => {
varsObject[v.name] = v.value;
});
setGlobalVariables(varsObject);
setHasUnsavedGlobalChanges(false);
logger.info('全局黑板配置已加载');
} catch (error) {
logger.error('加载全局黑板配置失败', error);
}
};
const saveGlobalBlackboard = async () => {
if (!projectPath) {
logger.error('未设置项目路径,无法保存全局黑板配置');
return;
}
try {
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
const json = globalBlackboard.toJSON();
await invoke('write_global_blackboard', { projectPath, content: json });
setHasUnsavedGlobalChanges(false);
logger.info('全局黑板配置已保存到', `${projectPath}/.ecs/global-blackboard.json`);
showToast('全局黑板已保存', 'success');
} catch (error) {
logger.error('保存全局黑板配置失败', error);
showToast('保存全局黑板失败', 'error');
}
};
const handleVariableChange = (key: string, value: any) => {
updateBlackboardVariable(key, value);
};
const handleVariableAdd = (key: string, value: any) => {
updateBlackboardVariable(key, value);
};
const handleVariableDelete = (key: string) => {
const newVars = { ...blackboardVariables };
delete newVars[key];
setBlackboardVariables(newVars);
};
const handleVariableRename = (oldKey: string, newKey: string) => {
if (oldKey === newKey) return;
const newVars = { ...blackboardVariables };
const value = newVars[oldKey];
delete newVars[oldKey];
newVars[newKey] = value;
setBlackboardVariables(newVars);
};
const handleGlobalVariableChange = (key: string, value: any) => {
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
globalBlackboard.setValue(key, value, true);
const allVars = globalBlackboard.getAllVariables();
const varsObject: Record<string, any> = {};
allVars.forEach((v) => {
varsObject[v.name] = v.value;
});
setGlobalVariables(varsObject);
setHasUnsavedGlobalChanges(true);
};
const handleGlobalVariableAdd = (key: string, value: any, type: BlackboardValueType) => {
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
globalBlackboard.defineVariable(key, type, value);
const allVars = globalBlackboard.getAllVariables();
const varsObject: Record<string, any> = {};
allVars.forEach((v) => {
varsObject[v.name] = v.value;
});
setGlobalVariables(varsObject);
setHasUnsavedGlobalChanges(true);
};
const handleGlobalVariableDelete = (key: string) => {
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
globalBlackboard.removeVariable(key);
const allVars = globalBlackboard.getAllVariables();
const varsObject: Record<string, any> = {};
allVars.forEach((v) => {
varsObject[v.name] = v.value;
});
setGlobalVariables(varsObject);
setHasUnsavedGlobalChanges(true);
};
const handlePropertyChange = (propertyName: string, value: any) => {
if (!selectedNode) return;
if (selectedNode.data.nodeType === 'blackboard-variable' && propertyName === '__blackboardValue') {
const varName = selectedNode.data.variableName;
if (varName) {
handleVariableChange(varName, value);
setSelectedNode({
...selectedNode,
data: {
...selectedNode.data,
__blackboardValue: value
}
});
}
return;
}
if (selectedNode.data.nodeType === 'blackboard-variable' && propertyName === 'variableName') {
const newVarValue = blackboardVariables[value];
const newVarType = typeof newVarValue === 'number' ? 'number' :
typeof newVarValue === 'boolean' ? 'boolean' : 'string';
updateNodes((nodes: any) => nodes.map((node: any) => {
if (node.id === selectedNode.id) {
return {
...node,
data: {
...node.data,
[propertyName]: value
},
template: {
...node.template,
displayName: value
}
};
}
return node;
}));
const updatedTemplate = {
...selectedNode.template,
displayName: value,
properties: [
{
name: 'variableName',
label: t('behaviorTree.variableName'),
type: 'variable' as const,
defaultValue: value,
description: t('behaviorTree.variableName'),
required: true
},
{
name: '__blackboardValue',
label: t('behaviorTree.currentValue'),
type: newVarType as 'string' | 'number' | 'boolean',
defaultValue: newVarValue,
description: t('behaviorTree.currentValue')
}
]
};
setSelectedNode({
...selectedNode,
template: updatedTemplate,
data: {
...selectedNode.data,
[propertyName]: value,
__blackboardValue: newVarValue
}
});
} else {
updateNodes((nodes: any) => nodes.map((node: any) => {
if (node.id === selectedNode.id) {
return {
...node,
data: {
...node.data,
[propertyName]: value
}
};
}
return node;
}));
setSelectedNode({
...selectedNode,
data: {
...selectedNode.data,
[propertyName]: value
}
});
}
};
return (
<div className="behavior-tree-properties-panel">
<div className="properties-panel-tabs">
<button
onClick={() => setActiveTab('properties')}
className={`tab-button ${activeTab === 'properties' ? 'active' : ''}`}
>
<Settings size={16} />
{t('behaviorTree.properties')}
</button>
<button
onClick={() => setActiveTab('blackboard')}
className={`tab-button ${activeTab === 'blackboard' ? 'active' : ''}`}
>
<Clipboard size={16} />
{t('behaviorTree.blackboard')}
</button>
</div>
<div className="properties-panel-content">
{activeTab === 'properties' ? (
<BehaviorTreeNodeProperties
selectedNode={selectedNode}
projectPath={projectPath}
onPropertyChange={handlePropertyChange}
/>
) : (
<BehaviorTreeBlackboard
variables={blackboardVariables}
initialVariables={isExecuting ? initialBlackboardVariables : undefined}
globalVariables={globalVariables}
onVariableChange={handleVariableChange}
onVariableAdd={handleVariableAdd}
onVariableDelete={handleVariableDelete}
onVariableRename={handleVariableRename}
onGlobalVariableChange={handleGlobalVariableChange}
onGlobalVariableAdd={handleGlobalVariableAdd}
onGlobalVariableDelete={handleGlobalVariableDelete}
projectPath={projectPath}
hasUnsavedGlobalChanges={hasUnsavedGlobalChanges}
onSaveGlobal={saveGlobalBlackboard}
/>
)}
</div>
</div>
);
};

View File

@@ -1,2 +0,0 @@
export { BehaviorTreeEditorPanel } from './BehaviorTreeEditorPanel';
export { BehaviorTreeNodePalettePanel } from './BehaviorTreeNodePalettePanel';