refactor(editor): 重构编辑器架构并增强行为树执行可视化
This commit is contained in:
@@ -97,6 +97,7 @@ export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
|
||||
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还没渲染完成,跳过此连接
|
||||
@@ -130,20 +131,10 @@ export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
|
||||
strokeWidth={20}
|
||||
/>
|
||||
|
||||
{/* 实际显示的线条 */}
|
||||
<path
|
||||
d={pathData.path}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
|
||||
{/* 箭头标记 */}
|
||||
{/* 箭头标记定义 */}
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
id={markerId}
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
refX="9"
|
||||
@@ -158,6 +149,16 @@ export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* 实际显示的线条 */}
|
||||
<path
|
||||
d={pathData.path}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
markerEnd={`url(#${markerId})`}
|
||||
/>
|
||||
|
||||
{/* 选中时显示的中点 */}
|
||||
{isSelected && (
|
||||
<circle
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { PropertyDefinition } from '@esengine/behavior-tree';
|
||||
import { BehaviorTreeNode as BehaviorTreeNodeType, Connection, ROOT_NODE_ID } from '../../../../stores/behaviorTreeStore';
|
||||
import { BehaviorTreeNode as BehaviorTreeNodeType, Connection, ROOT_NODE_ID, NodeExecutionStatus } from '../../../../stores/behaviorTreeStore';
|
||||
import { BehaviorTreeExecutor } from '../../../../utils/BehaviorTreeExecutor';
|
||||
import { BlackboardValue } from '../../../../domain/models/Blackboard';
|
||||
|
||||
@@ -22,6 +22,8 @@ interface BehaviorTreeNodeProps {
|
||||
blackboardVariables: BlackboardVariables;
|
||||
initialBlackboardVariables: BlackboardVariables;
|
||||
isExecuting: boolean;
|
||||
executionStatus?: NodeExecutionStatus;
|
||||
executionOrder?: number;
|
||||
connections: Connection[];
|
||||
nodes: BehaviorTreeNodeType[];
|
||||
executorRef: React.RefObject<BehaviorTreeExecutor | null>;
|
||||
@@ -44,6 +46,8 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
||||
blackboardVariables,
|
||||
initialBlackboardVariables,
|
||||
isExecuting,
|
||||
executionStatus,
|
||||
executionOrder,
|
||||
connections,
|
||||
nodes,
|
||||
executorRef,
|
||||
@@ -67,7 +71,8 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
||||
'bt-node',
|
||||
isSelected && 'selected',
|
||||
isRoot && 'root',
|
||||
isUncommitted && 'uncommitted'
|
||||
isUncommitted && 'uncommitted',
|
||||
executionStatus && executionStatus !== 'idle' && executionStatus
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
@@ -162,11 +167,33 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
||||
#{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: 'auto',
|
||||
marginLeft: executionOrder !== undefined ? '4px' : 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
@@ -191,7 +218,7 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
||||
<div
|
||||
className="bt-node-uncommitted-warning"
|
||||
style={{
|
||||
marginLeft: !isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) ? '4px' : 'auto',
|
||||
marginLeft: (executionOrder !== undefined || (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className))) ? '4px' : 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
@@ -220,7 +247,7 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
||||
<div
|
||||
className="bt-node-empty-warning-container"
|
||||
style={{
|
||||
marginLeft: isUncommitted ? '4px' : 'auto',
|
||||
marginLeft: (executionOrder !== undefined || (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) || isUncommitted) ? '4px' : 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'help',
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
.behavior-tree-editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.behavior-tree-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
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: var(--border-color);
|
||||
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: var(--text-primary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.behavior-tree-editor-toolbar .toolbar-btn:active:not(:disabled) {
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
.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: var(--text-secondary);
|
||||
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: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
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 var(--border-color);
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.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: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.blackboard-header .close-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.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: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px 0 0 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
z-index: 99;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.blackboard-toggle-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
width: 36px;
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Save, FolderOpen, Download, Play, Pause, Square, SkipForward, Clipboard, ChevronRight, ChevronLeft, Copy } 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 './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,
|
||||
nodes,
|
||||
connections,
|
||||
exportToJSON,
|
||||
exportToRuntimeAsset,
|
||||
importFromJSON,
|
||||
blackboardVariables,
|
||||
setBlackboardVariables,
|
||||
updateBlackboardVariable,
|
||||
initialBlackboardVariables,
|
||||
setInitialBlackboardVariables,
|
||||
isExecuting,
|
||||
setIsExecuting,
|
||||
saveNodesDataSnapshot,
|
||||
restoreNodesData,
|
||||
setIsOpen,
|
||||
reset
|
||||
} = 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 isInitialMount = useRef(true);
|
||||
const initialStateSnapshot = useRef<{ nodes: number; variables: number }>({ nodes: 0, variables: 0 });
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenFile = async (data: any) => {
|
||||
if (data.filePath && data.filePath.endsWith('.btree')) {
|
||||
try {
|
||||
const json = await invoke<string>('read_behavior_tree_file', { filePath: data.filePath });
|
||||
importFromJSON(json);
|
||||
setCurrentFilePath(data.filePath);
|
||||
setHasUnsavedChanges(false);
|
||||
isInitialMount.current = true;
|
||||
initialStateSnapshot.current = {
|
||||
nodes: nodes.length,
|
||||
variables: Object.keys(blackboardVariables).length
|
||||
};
|
||||
logger.info('行为树已加载', data.filePath);
|
||||
showToast(`已打开 ${data.filePath.split(/[\\/]/).pop()?.replace('.btree', '')}`, 'success');
|
||||
} catch (error) {
|
||||
logger.error('加载行为树失败', error);
|
||||
showToast(`加载失败: ${error}`, 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = messageHub?.subscribe('behavior-tree:open-file', handleOpenFile);
|
||||
return () => unsubscribe?.();
|
||||
}, [messageHub, importFromJSON, nodes.length, blackboardVariables]);
|
||||
|
||||
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 json = await invoke<string>('read_behavior_tree_file', { filePath: selected as string });
|
||||
importFromJSON(json);
|
||||
setCurrentFilePath(selected as string);
|
||||
setHasUnsavedChanges(false);
|
||||
isInitialMount.current = true;
|
||||
logger.info('行为树已加载', selected);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.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);
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="behavior-tree-editor-panel">
|
||||
<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={() => 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
.behavior-tree-node-palette-panel {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BehaviorTreeEditorPanel } from './BehaviorTreeEditorPanel';
|
||||
export { BehaviorTreeNodePalettePanel } from './BehaviorTreeNodePalettePanel';
|
||||
@@ -1,19 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Trash2, Replace, Plus } from 'lucide-react';
|
||||
|
||||
interface NodeContextMenuProps {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
nodeId: string | null;
|
||||
onReplaceNode: () => void;
|
||||
onReplaceNode?: () => void;
|
||||
onDeleteNode?: () => void;
|
||||
onCreateNode?: () => void;
|
||||
}
|
||||
|
||||
export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
||||
visible,
|
||||
position,
|
||||
onReplaceNode
|
||||
nodeId,
|
||||
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={{
|
||||
@@ -30,20 +47,46 @@ export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
onClick={onReplaceNode}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
color: '#cccccc',
|
||||
fontSize: '13px',
|
||||
transition: 'background-color 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
替换节点
|
||||
</div>
|
||||
{nodeId ? (
|
||||
<>
|
||||
{onReplaceNode && (
|
||||
<div
|
||||
onClick={onReplaceNode}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Replace size={14} />
|
||||
替换节点
|
||||
</div>
|
||||
)}
|
||||
{onDeleteNode && (
|
||||
<div
|
||||
onClick={onDeleteNode}
|
||||
style={{...menuItemStyle, color: '#f48771'}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#5a1a1a'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除节点
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{onCreateNode && (
|
||||
<div
|
||||
onClick={onCreateNode}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
新建节点
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { NodeTemplate, NodeTemplates } from '@esengine/behavior-tree';
|
||||
import { Search, X, LucideIcon } from 'lucide-react';
|
||||
import { Search, X, LucideIcon, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface QuickCreateMenuProps {
|
||||
visible: boolean;
|
||||
@@ -15,6 +15,12 @@ interface QuickCreateMenuProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CategoryGroup {
|
||||
category: string;
|
||||
templates: NodeTemplate[];
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
visible,
|
||||
position,
|
||||
@@ -27,6 +33,8 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
onClose
|
||||
}) => {
|
||||
const selectedNodeRef = useRef<HTMLDivElement>(null);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
const [shouldAutoScroll, setShouldAutoScroll] = useState(false);
|
||||
|
||||
const allTemplates = NodeTemplates.getAllTemplates();
|
||||
const searchTextLower = searchText.toLowerCase();
|
||||
@@ -40,17 +48,63 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
})
|
||||
: 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 (selectedNodeRef.current) {
|
||||
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]);
|
||||
}, [selectedIndex, shouldAutoScroll]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
let globalIndex = -1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@@ -67,6 +121,12 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
.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={{
|
||||
@@ -74,7 +134,7 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '300px',
|
||||
maxHeight: '400px',
|
||||
maxHeight: '500px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
@@ -109,13 +169,15 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
onClose();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
onIndexChange(Math.min(selectedIndex + 1, filteredTemplates.length - 1));
|
||||
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' && filteredTemplates.length > 0) {
|
||||
} else if (e.key === 'Enter' && flattenedTemplates.length > 0) {
|
||||
e.preventDefault();
|
||||
const selectedTemplate = filteredTemplates[selectedIndex];
|
||||
const selectedTemplate = flattenedTemplates[selectedIndex];
|
||||
if (selectedTemplate) {
|
||||
onNodeSelect(selectedTemplate);
|
||||
}
|
||||
@@ -153,10 +215,10 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '8px'
|
||||
padding: '4px'
|
||||
}}
|
||||
>
|
||||
{filteredTemplates.length === 0 ? (
|
||||
{categoryGroups.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
@@ -166,71 +228,113 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||
未找到匹配的节点
|
||||
</div>
|
||||
) : (
|
||||
filteredTemplates.map((template: NodeTemplate, index: number) => {
|
||||
const IconComponent = template.icon ? iconMap[template.icon] : null;
|
||||
const className = template.className || '';
|
||||
const isSelected = index === selectedIndex;
|
||||
categoryGroups.map((group) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
ref={isSelected ? selectedNodeRef : null}
|
||||
onClick={() => onNodeSelect(template)}
|
||||
onMouseEnter={() => onIndexChange(index)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
marginBottom: '4px',
|
||||
backgroundColor: isSelected ? '#0e639c' : '#1e1e1e',
|
||||
borderLeft: `3px solid ${template.color || '#666'}`,
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
transform: isSelected ? 'translateX(2px)' : 'translateX(0)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{IconComponent && (
|
||||
<IconComponent size={14} style={{ color: template.color || '#999', flexShrink: 0 }} />
|
||||
<div 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 }} />
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
<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 style={{
|
||||
fontSize: '11px',
|
||||
color: '#999',
|
||||
lineHeight: '1.4',
|
||||
marginBottom: '2px'
|
||||
}}>
|
||||
{template.description}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#666'
|
||||
}}>
|
||||
{template.category}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user