refactor(editor): 重构编辑器架构并增强行为树执行可视化

This commit is contained in:
YHH
2025-11-04 18:29:28 +08:00
parent adfc7e91b3
commit f9afa22406
44 changed files with 4942 additions and 546 deletions

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3 } from 'lucide-react';
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3, LayoutGrid, List } from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { FileTree } from './FileTree';
import { ResizablePanel } from './ResizablePanel';
@@ -13,21 +13,30 @@ interface AssetItem {
path: string;
type: 'file' | 'folder';
extension?: string;
size?: number;
modified?: number;
}
interface AssetBrowserProps {
projectPath: string | null;
locale: string;
onOpenScene?: (scenePath: string) => void;
onOpenBehaviorTree?: (btreePath: string) => void;
}
export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorTree }: AssetBrowserProps) {
export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserProps) {
const messageHub = Core.services.resolve(MessageHub);
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [assets, setAssets] = useState<AssetItem[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<AssetItem[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [loading, setLoading] = useState(false);
const [showDetailView, setShowDetailView] = useState(() => {
const saved = localStorage.getItem('asset-browser-detail-view');
return saved !== null ? saved === 'true' : false;
});
const [contextMenu, setContextMenu] = useState<{
position: { x: number; y: number };
asset: AssetItem;
@@ -105,7 +114,9 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
name: entry.name,
path: entry.path,
type: entry.is_dir ? 'folder' as const : 'file' as const,
extension
extension,
size: entry.size,
modified: entry.modified
};
});
@@ -121,6 +132,73 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
}
};
const searchProjectRecursively = async (rootPath: string, query: string): Promise<AssetItem[]> => {
const results: AssetItem[] = [];
const lowerQuery = query.toLowerCase();
const searchDirectory = async (dirPath: string) => {
try {
const entries = await TauriAPI.listDirectory(dirPath);
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
if (entry.name.toLowerCase().includes(lowerQuery)) {
const extension = entry.is_dir ? undefined :
(entry.name.includes('.') ? entry.name.split('.').pop() : undefined);
results.push({
name: entry.name,
path: entry.path,
type: entry.is_dir ? 'folder' as const : 'file' as const,
extension,
size: entry.size,
modified: entry.modified
});
}
if (entry.is_dir) {
await searchDirectory(entry.path);
}
}
} catch (error) {
console.error(`Failed to search directory ${dirPath}:`, error);
}
};
await searchDirectory(rootPath);
return results.sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
});
};
useEffect(() => {
const performSearch = async () => {
if (!searchQuery.trim()) {
setSearchResults([]);
setIsSearching(false);
return;
}
if (!projectPath) return;
setIsSearching(true);
try {
const results = await searchProjectRecursively(projectPath, searchQuery);
setSearchResults(results);
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
};
const timeoutId = setTimeout(performSearch, 300);
return () => clearTimeout(timeoutId);
}, [searchQuery, projectPath]);
const handleFolderSelect = (path: string) => {
setCurrentPath(path);
loadAssets(path);
@@ -128,6 +206,17 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
const handleAssetClick = (asset: AssetItem) => {
setSelectedPath(asset.path);
messageHub?.publish('asset-file:selected', {
fileInfo: {
name: asset.name,
path: asset.path,
extension: asset.extension,
size: asset.size,
modified: asset.modified,
isDirectory: asset.type === 'folder'
}
});
};
const handleAssetDoubleClick = async (asset: AssetItem) => {
@@ -137,15 +226,25 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
} else if (asset.type === 'file') {
if (asset.extension === 'ecs' && onOpenScene) {
onOpenScene(asset.path);
} else if (asset.extension === 'btree' && onOpenBehaviorTree) {
onOpenBehaviorTree(asset.path);
} else {
// 其他文件使用系统默认程序打开
try {
await TauriAPI.openFileWithSystemApp(asset.path);
} catch (error) {
console.error('Failed to open file:', error);
return;
}
if (fileActionRegistry) {
console.log('[AssetBrowser] Handling double click for:', asset.path);
console.log('[AssetBrowser] Extension:', asset.extension);
const handled = await fileActionRegistry.handleDoubleClick(asset.path);
console.log('[AssetBrowser] Handled by plugin:', handled);
if (handled) {
return;
}
} else {
console.log('[AssetBrowser] FileActionRegistry not available');
}
try {
await TauriAPI.openFileWithSystemApp(asset.path);
} catch (error) {
console.error('Failed to open file:', error);
}
}
};
@@ -168,6 +267,27 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
icon: <File size={16} />,
onClick: () => handleAssetDoubleClick(asset)
});
if (fileActionRegistry) {
const handlers = fileActionRegistry.getHandlersForFile(asset.path);
for (const handler of handlers) {
if (handler.getContextMenuItems) {
const parentPath = asset.path.substring(0, asset.path.lastIndexOf('/'));
const pluginItems = handler.getContextMenuItems(asset.path, parentPath);
for (const pluginItem of pluginItems) {
items.push({
label: pluginItem.label,
icon: pluginItem.icon,
onClick: () => pluginItem.onClick(asset.path, parentPath),
disabled: pluginItem.disabled,
separator: pluginItem.separator
});
}
}
}
}
items.push({ label: '', separator: true, onClick: () => {} });
}
// 在文件管理器中显示
@@ -238,11 +358,14 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
return crumbs;
};
const filteredAssets = searchQuery
? assets.filter((asset) =>
asset.name.toLowerCase().includes(searchQuery.toLowerCase())
)
: assets;
const filteredAssets = searchQuery.trim() ? searchResults : assets;
const getRelativePath = (fullPath: string): string => {
if (!projectPath) return fullPath;
const relativePath = fullPath.replace(projectPath, '').replace(/^[/\\]/, '');
const parts = relativePath.split(/[/\\]/);
return parts.slice(0, -1).join('/');
};
const getFileIcon = (asset: AssetItem) => {
if (asset.type === 'folder') {
@@ -289,26 +412,102 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
return (
<div className="asset-browser">
<div className="asset-browser-header">
<h3>{t.title}</h3>
</div>
<div className="asset-browser-content">
<ResizablePanel
direction="horizontal"
defaultSize={200}
minSize={150}
maxSize={400}
leftOrTop={
<div className="asset-browser-tree">
<FileTree
rootPath={projectPath}
onSelectFile={handleFolderSelect}
selectedPath={currentPath}
/>
</div>
}
rightOrBottom={
<div style={{
padding: '8px',
borderBottom: '1px solid #3e3e3e',
display: 'flex',
gap: '8px',
background: '#252526',
alignItems: 'center'
}}>
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={() => {
setShowDetailView(true);
localStorage.setItem('asset-browser-detail-view', 'true');
}}
style={{
padding: '6px 12px',
background: showDetailView ? '#0e639c' : 'transparent',
border: '1px solid #3e3e3e',
borderRadius: '3px',
color: showDetailView ? '#ffffff' : '#cccccc',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
transition: 'all 0.2s',
fontSize: '12px',
fontWeight: showDetailView ? '500' : 'normal'
}}
title="显示详细视图(树形图 + 资产列表)"
>
<LayoutGrid size={14} />
</button>
<button
onClick={() => {
setShowDetailView(false);
localStorage.setItem('asset-browser-detail-view', 'false');
}}
style={{
padding: '6px 12px',
background: !showDetailView ? '#0e639c' : 'transparent',
border: '1px solid #3e3e3e',
borderRadius: '3px',
color: !showDetailView ? '#ffffff' : '#cccccc',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
transition: 'all 0.2s',
fontSize: '12px',
fontWeight: !showDetailView ? '500' : 'normal'
}}
title="仅显示树形图(查看完整路径)"
>
<List size={14} />
</button>
</div>
<input
type="text"
className="asset-search"
placeholder={t.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
flex: 1,
padding: '6px 10px',
background: '#3c3c3c',
border: '1px solid #3e3e3e',
borderRadius: '3px',
color: '#cccccc',
fontSize: '12px',
outline: 'none'
}}
/>
</div>
{showDetailView ? (
<ResizablePanel
direction="horizontal"
defaultSize={200}
minSize={150}
maxSize={400}
leftOrTop={
<div className="asset-browser-tree">
<FileTree
rootPath={projectPath}
onSelectFile={handleFolderSelect}
selectedPath={currentPath}
messageHub={messageHub}
searchQuery={searchQuery}
showFiles={false}
/>
</div>
}
rightOrBottom={
<div className="asset-browser-list">
<div className="asset-browser-breadcrumb">
{breadcrumbs.map((crumb, index) => (
@@ -326,47 +525,65 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
</span>
))}
</div>
<div className="asset-browser-toolbar">
<input
type="text"
className="asset-search"
placeholder={t.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{loading ? (
{(loading || isSearching) ? (
<div className="asset-browser-loading">
<p>{t.loading}</p>
<p>{isSearching ? '搜索中...' : t.loading}</p>
</div>
) : filteredAssets.length === 0 ? (
<div className="asset-browser-empty">
<p>{t.empty}</p>
<p>{searchQuery.trim() ? '未找到匹配的资产' : t.empty}</p>
</div>
) : (
<div className="asset-list">
{filteredAssets.map((asset, index) => (
<div
key={index}
className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
onClick={() => handleAssetClick(asset)}
onDoubleClick={() => handleAssetDoubleClick(asset)}
onContextMenu={(e) => handleContextMenu(e, asset)}
>
{getFileIcon(asset)}
<div className="asset-name" title={asset.name}>
{asset.name}
{filteredAssets.map((asset, index) => {
const relativePath = getRelativePath(asset.path);
const showPath = searchQuery.trim() && relativePath;
return (
<div
key={index}
className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
onClick={() => handleAssetClick(asset)}
onDoubleClick={() => handleAssetDoubleClick(asset)}
onContextMenu={(e) => handleContextMenu(e, asset)}
>
{getFileIcon(asset)}
<div className="asset-info">
<div className="asset-name" title={asset.path}>
{asset.name}
</div>
{showPath && (
<div className="asset-path" style={{
fontSize: '11px',
color: '#666',
marginTop: '2px'
}}>
{relativePath}
</div>
)}
</div>
<div className="asset-type">
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
</div>
</div>
<div className="asset-type">
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
</div>
</div>
))}
);
})}
</div>
)}
</div>
}
/>
) : (
<div className="asset-browser-tree-only">
<FileTree
rootPath={projectPath}
onSelectFile={handleFolderSelect}
selectedPath={currentPath}
messageHub={messageHub}
searchQuery={searchQuery}
showFiles={true}
/>
</div>
)}
</div>
{contextMenu && (
<ContextMenu

View File

@@ -237,7 +237,7 @@ export const BehaviorTreeBlackboard: React.FC<BehaviorTreeBlackboardProps> = ({
{/* 标题栏 */}
<div style={{
backgroundColor: '#2d2d2d',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderBottom: '1px solid #333'
}}>
<div style={{

View File

@@ -3,7 +3,6 @@ import { NodeTemplate } from '@esengine/behavior-tree';
import { RotateCcw } from 'lucide-react';
import { useBehaviorTreeStore, BehaviorTreeNode, ROOT_NODE_ID } from '../stores/behaviorTreeStore';
import { useUIStore } from '../application/state/UIStore';
import { BehaviorTreeExecutionPanel } from './BehaviorTreeExecutionPanel';
import { useToast } from './Toast';
import { BlackboardValue } from '../domain/models/Blackboard';
import { BehaviorTreeCanvas } from '../presentation/components/behavior-tree/canvas/BehaviorTreeCanvas';
@@ -39,13 +38,15 @@ interface BehaviorTreeEditorProps {
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
blackboardVariables?: BlackboardVariables;
projectPath?: string | null;
showToolbar?: boolean;
}
export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
onNodeSelect,
onNodeCreate,
blackboardVariables = {},
projectPath = null
projectPath = null,
showToolbar = true
}) => {
const { showToast } = useToast();
@@ -75,7 +76,11 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
setInitialBlackboardVariables,
setIsExecuting,
initialBlackboardVariables,
isExecuting
isExecuting,
saveNodesDataSnapshot,
restoreNodesData,
nodeExecutionStatuses,
nodeExecutionOrders
} = useBehaviorTreeStore();
// UI store选中、拖拽、画布状态
@@ -107,7 +112,7 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
const connectionOperations = useConnectionOperations(validator, commandManager);
// 右键菜单
const { contextMenu, setContextMenu, handleNodeContextMenu, closeContextMenu } = useContextMenu();
const { contextMenu, setContextMenu, handleNodeContextMenu, handleCanvasContextMenu, closeContextMenu } = useContextMenu();
// 组件挂载和连线变化时强制更新,确保连线能正确渲染
useEffect(() => {
@@ -164,7 +169,9 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
initialBlackboardVariables,
onBlackboardUpdate: setBlackboardVariables,
onInitialBlackboardSave: setInitialBlackboardVariables,
onExecutingChange: setIsExecuting
onExecutingChange: setIsExecuting,
onSaveNodesDataSnapshot: saveNodesDataSnapshot,
onRestoreNodesData: restoreNodesData
});
executorRef.current = controller['executor'] || null;
@@ -296,7 +303,8 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
setSelectedConnection,
setQuickCreateMenu,
clearConnecting,
clearBoxSelect
clearBoxSelect,
showToast
});
@@ -373,6 +381,7 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
handleNodeMouseUp();
handleCanvasMouseUp(e);
}}
onContextMenu={handleCanvasContextMenu}
>
{/* 连接线层 */}
<ConnectionLayer
@@ -473,6 +482,8 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
{nodes.map((node: BehaviorTreeNode) => {
const isSelected = selectedNodeIds.includes(node.id);
const isBeingDragged = dragStartPositions.has(node.id);
const executionStatus = nodeExecutionStatuses.get(node.id);
const executionOrder = nodeExecutionOrders.get(node.id);
return (
<BehaviorTreeNodeComponent
@@ -485,6 +496,8 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
blackboardVariables={blackboardVariables}
initialBlackboardVariables={initialBlackboardVariables}
isExecuting={isExecuting}
executionStatus={executionStatus}
executionOrder={executionOrder}
connections={connections}
nodes={nodes}
executorRef={executorRef}
@@ -543,20 +556,22 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
</BehaviorTreeCanvas>
{/* 运行控制工具栏 */}
<EditorToolbar
executionMode={executionMode}
canUndo={canUndo}
canRedo={canRedo}
onPlay={handlePlay}
onPause={handlePause}
onStop={handleStop}
onStep={handleStep}
onReset={handleReset}
onUndo={undo}
onRedo={redo}
onResetView={handleResetView}
onClearCanvas={handleClearCanvas}
/>
{showToolbar && (
<EditorToolbar
executionMode={executionMode}
canUndo={canUndo}
canRedo={canRedo}
onPlay={handlePlay}
onPause={handlePause}
onStop={handleStop}
onStep={handleStep}
onReset={handleReset}
onUndo={undo}
onRedo={redo}
onResetView={handleResetView}
onClearCanvas={handleClearCanvas}
/>
)}
{/* 快速创建菜单 */}
<QuickCreateMenu
@@ -566,14 +581,14 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
selectedIndex={quickCreateMenu.selectedIndex}
mode={quickCreateMenu.mode}
iconMap={ICON_MAP}
onSearchChange={(text) => setQuickCreateMenu({
...quickCreateMenu,
onSearchChange={(text) => setQuickCreateMenu(prev => ({
...prev,
searchText: text
})}
onIndexChange={(index) => setQuickCreateMenu({
...quickCreateMenu,
}))}
onIndexChange={(index) => setQuickCreateMenu(prev => ({
...prev,
selectedIndex: index
})}
}))}
onNodeSelect={handleQuickCreateNode}
onClose={() => {
setQuickCreateMenu({
@@ -615,21 +630,6 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
</div>
</div>
{/* 执行面板 */}
<div style={{
height: '250px',
borderTop: '1px solid #333'
}}>
<BehaviorTreeExecutionPanel
logs={executionLogs}
onClearLogs={() => setExecutionLogs([])}
isRunning={executionMode === 'running'}
tickCount={tickCount}
executionSpeed={executionSpeed}
onSpeedChange={handleSpeedChange}
/>
</div>
{/* 右键菜单 */}
<NodeContextMenu
visible={contextMenu.visible}
@@ -646,6 +646,23 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
});
setContextMenu({ ...contextMenu, visible: false });
}}
onDeleteNode={() => {
if (contextMenu.nodeId) {
nodeOperations.deleteNode(contextMenu.nodeId);
setContextMenu({ ...contextMenu, visible: false });
}
}}
onCreateNode={() => {
setQuickCreateMenu({
visible: true,
position: contextMenu.position,
searchText: '',
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
setContextMenu({ ...contextMenu, visible: false });
}}
/>
</div>
);

View File

@@ -39,6 +39,13 @@ export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProp
const { t } = useTranslation();
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
const [assetPickerProperty, setAssetPickerProperty] = useState<string | null>(null);
const [isComposing, setIsComposing] = useState(false);
const [localValues, setLocalValues] = useState<Record<string, any>>({});
// 当节点切换时,清空本地状态
React.useEffect(() => {
setLocalValues({});
}, [selectedNode?.template.className]);
if (!selectedNode) {
return (
@@ -58,11 +65,31 @@ export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProp
const { template, data } = selectedNode;
const handleChange = (propName: string, value: any) => {
if (!isComposing) {
onPropertyChange?.(propName, value);
}
};
const handleInputChange = (propName: string, value: any) => {
setLocalValues(prev => ({ ...prev, [propName]: value }));
if (!isComposing) {
onPropertyChange?.(propName, value);
}
};
const handleCompositionStart = () => {
setIsComposing(true);
};
const handleCompositionEnd = (propName: string, value: any) => {
setIsComposing(false);
onPropertyChange?.(propName, value);
};
const renderProperty = (prop: PropertyDefinition) => {
const value = data[prop.name] ?? prop.defaultValue;
const propName = prop.name;
const hasLocalValue = propName in localValues;
const value = hasLocalValue ? localValues[propName] : (data[prop.name] ?? prop.defaultValue);
switch (prop.type) {
case 'string':
@@ -71,7 +98,10 @@ export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProp
<input
type="text"
value={value || ''}
onChange={(e) => handleChange(prop.name, e.target.value)}
onChange={(e) => handleInputChange(propName, e.target.value)}
onCompositionStart={handleCompositionStart}
onCompositionEnd={(e) => handleCompositionEnd(propName, (e.target as HTMLInputElement).value)}
onBlur={(e) => onPropertyChange?.(propName, e.target.value)}
placeholder={prop.description}
style={{
width: '100%',
@@ -148,7 +178,10 @@ export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProp
return (
<textarea
value={value || ''}
onChange={(e) => handleChange(prop.name, e.target.value)}
onChange={(e) => handleInputChange(propName, e.target.value)}
onCompositionStart={handleCompositionStart}
onCompositionEnd={(e) => handleCompositionEnd(propName, (e.target as HTMLTextAreaElement).value)}
onBlur={(e) => onPropertyChange?.(propName, e.target.value)}
placeholder={prop.description}
rows={5}
style={{
@@ -171,7 +204,10 @@ export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProp
<input
type="text"
value={value || ''}
onChange={(e) => handleChange(prop.name, e.target.value)}
onChange={(e) => handleInputChange(propName, e.target.value)}
onCompositionStart={handleCompositionStart}
onCompositionEnd={(e) => handleCompositionEnd(propName, (e.target as HTMLInputElement).value)}
onBlur={(e) => onPropertyChange?.(propName, e.target.value)}
placeholder="黑板变量名"
style={{
flex: 1,
@@ -340,43 +376,6 @@ export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProp
)}
</div>
{/* 操作按钮 */}
<div style={{
padding: '15px',
borderTop: '1px solid #333',
display: 'flex',
gap: '10px'
}}>
<button
style={{
flex: 1,
padding: '8px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
fontSize: '13px'
}}
>
{t('behaviorTree.apply')}
</button>
<button
style={{
flex: 1,
padding: '8px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '3px',
color: '#cccccc',
cursor: 'pointer',
fontSize: '13px'
}}
>
{t('behaviorTree.reset')}
</button>
</div>
{/* 资产选择器对话框 */}
{assetPickerOpen && projectPath && assetPickerProperty && (
<AssetPickerDialog

View File

@@ -1,12 +1,19 @@
import { useState, useEffect } from 'react';
import { Folder, ChevronRight, ChevronDown } from 'lucide-react';
import { Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, ChevronsDown, ChevronsUp } from 'lucide-react';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
import { ContextMenu, ContextMenuItem } from './ContextMenu';
import { ConfirmDialog } from './ConfirmDialog';
import { PromptDialog } from './PromptDialog';
import '../styles/FileTree.css';
interface TreeNode {
name: string;
path: string;
type: 'folder';
type: 'folder' | 'file';
size?: number;
modified?: number;
children?: TreeNode[];
expanded?: boolean;
loaded?: boolean;
@@ -16,11 +23,30 @@ interface FileTreeProps {
rootPath: string | null;
onSelectFile?: (path: string) => void;
selectedPath?: string | null;
messageHub?: MessageHub;
searchQuery?: string;
showFiles?: boolean;
}
export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps) {
export function FileTree({ rootPath, onSelectFile, selectedPath, messageHub, searchQuery, showFiles = true }: FileTreeProps) {
const [tree, setTree] = useState<TreeNode[]>([]);
const [loading, setLoading] = useState(false);
const [internalSelectedPath, setInternalSelectedPath] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{
position: { x: number; y: number };
node: TreeNode | null;
} | null>(null);
const [renamingNode, setRenamingNode] = useState<string | null>(null);
const [newName, setNewName] = useState('');
const [deleteDialog, setDeleteDialog] = useState<{ node: TreeNode } | null>(null);
const [promptDialog, setPromptDialog] = useState<{
type: 'create-file' | 'create-folder' | 'create-template';
parentPath: string;
templateExtension?: string;
templateContent?: (fileName: string) => Promise<string>;
} | null>(null);
const [filteredTree, setFilteredTree] = useState<TreeNode[]>([]);
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
useEffect(() => {
if (rootPath) {
@@ -30,6 +56,74 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
}
}, [rootPath]);
useEffect(() => {
if (selectedPath) {
setInternalSelectedPath(selectedPath);
}
}, [selectedPath]);
useEffect(() => {
const performSearch = async () => {
const filterByFileType = (nodes: TreeNode[]): TreeNode[] => {
return nodes
.filter(node => showFiles || node.type === 'folder')
.map(node => ({
...node,
children: node.children ? filterByFileType(node.children) : node.children
}));
};
let result = filterByFileType(tree);
if (searchQuery && searchQuery.trim()) {
const query = searchQuery.toLowerCase();
const loadAndFilterTree = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
const filtered: TreeNode[] = [];
for (const node of nodes) {
const nameMatches = node.name.toLowerCase().includes(query);
let filteredChildren: TreeNode[] = [];
if (node.type === 'folder') {
let childrenToSearch = node.children || [];
if (!node.loaded) {
try {
const entries = await TauriAPI.listDirectory(node.path);
childrenToSearch = entriesToNodes(entries);
} catch (error) {
console.error('Failed to load children for search:', error);
}
}
if (childrenToSearch.length > 0) {
filteredChildren = await loadAndFilterTree(childrenToSearch);
}
}
if (nameMatches || filteredChildren.length > 0) {
filtered.push({
...node,
expanded: filteredChildren.length > 0,
loaded: true,
children: filteredChildren.length > 0 ? filteredChildren : (node.type === 'folder' ? [] : undefined)
});
}
}
return filtered;
};
result = await loadAndFilterTree(result);
}
setFilteredTree(result);
};
performSearch();
}, [searchQuery, tree, showFiles]);
const loadRootDirectory = async (path: string) => {
setLoading(true);
try {
@@ -57,16 +151,20 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
};
const entriesToNodes = (entries: DirectoryEntry[]): TreeNode[] => {
// 只显示文件夹,过滤掉文件
return entries
.filter((entry) => entry.is_dir)
.sort((a, b) => {
if (a.is_dir === b.is_dir) return a.name.localeCompare(b.name);
return a.is_dir ? -1 : 1;
})
.map((entry) => ({
name: entry.name,
path: entry.path,
type: 'folder' as const,
children: [],
type: entry.is_dir ? 'folder' as const : 'file' as const,
size: entry.size,
modified: entry.modified,
children: entry.is_dir ? [] : undefined,
expanded: false,
loaded: false
loaded: entry.is_dir ? false : undefined
}));
};
@@ -115,13 +213,343 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
setTree(newTree);
};
const refreshTree = async () => {
if (rootPath) {
await loadRootDirectory(rootPath);
}
};
const expandAll = async () => {
const expandNode = async (node: TreeNode): Promise<TreeNode> => {
if (node.type === 'folder') {
let children = node.children || [];
if (!node.loaded) {
try {
const entries = await TauriAPI.listDirectory(node.path);
children = entriesToNodes(entries);
} catch (error) {
console.error('Failed to load children:', error);
children = [];
}
}
const expandedChildren = await Promise.all(
children.map(child => expandNode(child))
);
return {
...node,
expanded: true,
loaded: true,
children: expandedChildren
};
}
return node;
};
const expandedTree = await Promise.all(tree.map(node => expandNode(node)));
setTree(expandedTree);
};
const collapseAll = () => {
const collapseNode = (node: TreeNode): TreeNode => {
if (node.type === 'folder') {
return {
...node,
expanded: false,
children: node.children ? node.children.map(collapseNode) : node.children
};
}
return node;
};
const collapsedTree = tree.map(node => collapseNode(node));
setTree(collapsedTree);
};
const handleRename = async (node: TreeNode) => {
if (!newName || newName === node.name) {
setRenamingNode(null);
return;
}
const pathParts = node.path.split(/[/\\]/);
pathParts[pathParts.length - 1] = newName;
const newPath = pathParts.join('/');
try {
await TauriAPI.renameFileOrFolder(node.path, newPath);
await refreshTree();
setRenamingNode(null);
setNewName('');
} catch (error) {
console.error('Failed to rename:', error);
alert(`重命名失败: ${error}`);
}
};
const handleDeleteClick = (node: TreeNode) => {
setContextMenu(null);
setDeleteDialog({ node });
};
const handleDeleteConfirm = async () => {
if (!deleteDialog) return;
const node = deleteDialog.node;
setDeleteDialog(null);
try {
if (node.type === 'folder') {
await TauriAPI.deleteFolder(node.path);
} else {
await TauriAPI.deleteFile(node.path);
}
await refreshTree();
} catch (error) {
console.error('Failed to delete:', error);
alert(`删除失败: ${error}`);
}
};
const handleCreateFileClick = (parentPath: string) => {
setContextMenu(null);
setPromptDialog({ type: 'create-file', parentPath });
};
const handleCreateFolderClick = (parentPath: string) => {
setContextMenu(null);
setPromptDialog({ type: 'create-folder', parentPath });
};
const handleCreateTemplateFileClick = (parentPath: string, template: any) => {
setContextMenu(null);
setPromptDialog({
type: 'create-template',
parentPath,
templateExtension: template.extension,
templateContent: template.createContent
});
};
const handlePromptConfirm = async (value: string) => {
if (!promptDialog) return;
const { type, parentPath, templateExtension, templateContent } = promptDialog;
setPromptDialog(null);
let fileName = value;
let targetPath = `${parentPath}/${value}`;
try {
if (type === 'create-file') {
await TauriAPI.createFile(targetPath);
} else if (type === 'create-folder') {
await TauriAPI.createDirectory(targetPath);
} else if (type === 'create-template' && templateExtension && templateContent) {
if (!fileName.endsWith(`.${templateExtension}`)) {
fileName = `${fileName}.${templateExtension}`;
targetPath = `${parentPath}/${fileName}`;
}
const content = await templateContent(fileName);
await TauriAPI.writeFileContent(targetPath, content);
}
await refreshTree();
} catch (error) {
console.error(`Failed to ${type}:`, error);
alert(`${type === 'create-file' ? '创建文件' : type === 'create-folder' ? '创建文件夹' : '创建模板文件'}失败: ${error}`);
}
};
const getContextMenuItems = (node: TreeNode | null): ContextMenuItem[] => {
if (!node) {
const baseItems: ContextMenuItem[] = [
{
label: '新建文件',
icon: <FileText size={16} />,
onClick: () => rootPath && handleCreateFileClick(rootPath)
},
{
label: '新建文件夹',
icon: <FolderPlus size={16} />,
onClick: () => rootPath && handleCreateFolderClick(rootPath)
}
];
if (fileActionRegistry && rootPath) {
const templates = fileActionRegistry.getCreationTemplates();
if (templates.length > 0) {
baseItems.push({ label: '', separator: true, onClick: () => {} });
for (const template of templates) {
baseItems.push({
label: template.label,
icon: template.icon,
onClick: () => handleCreateTemplateFileClick(rootPath, template)
});
}
}
}
return baseItems;
}
const items: ContextMenuItem[] = [];
if (node.type === 'file') {
items.push({
label: '打开文件',
icon: <File size={16} />,
onClick: async () => {
try {
await TauriAPI.openFileWithSystemApp(node.path);
} catch (error) {
console.error('Failed to open file:', error);
}
}
});
if (fileActionRegistry) {
const handlers = fileActionRegistry.getHandlersForFile(node.path);
for (const handler of handlers) {
if (handler.getContextMenuItems) {
const parentPath = node.path.substring(0, node.path.lastIndexOf('/'));
const pluginItems = handler.getContextMenuItems(node.path, parentPath);
for (const pluginItem of pluginItems) {
items.push({
label: pluginItem.label,
icon: pluginItem.icon,
onClick: () => pluginItem.onClick(node.path, parentPath),
disabled: pluginItem.disabled,
separator: pluginItem.separator
});
}
}
}
}
}
items.push({
label: '重命名',
icon: <Edit3 size={16} />,
onClick: () => {
setRenamingNode(node.path);
setNewName(node.name);
}
});
items.push({
label: '删除',
icon: <Trash2 size={16} />,
onClick: () => handleDeleteClick(node)
});
items.push({ label: '', separator: true, onClick: () => {} });
if (node.type === 'folder') {
items.push({
label: '新建文件',
icon: <FileText size={16} />,
onClick: () => handleCreateFileClick(node.path)
});
items.push({
label: '新建文件夹',
icon: <FolderPlus size={16} />,
onClick: () => handleCreateFolderClick(node.path)
});
if (fileActionRegistry) {
const templates = fileActionRegistry.getCreationTemplates();
if (templates.length > 0) {
items.push({ label: '', separator: true, onClick: () => {} });
for (const template of templates) {
items.push({
label: template.label,
icon: template.icon,
onClick: () => handleCreateTemplateFileClick(node.path, template)
});
}
}
}
items.push({ label: '', separator: true, onClick: () => {} });
}
items.push({
label: '在文件管理器中显示',
icon: <FolderOpen size={16} />,
onClick: async () => {
try {
await TauriAPI.showInFolder(node.path);
} catch (error) {
console.error('Failed to show in folder:', error);
}
}
});
items.push({
label: '复制路径',
icon: <Copy size={16} />,
onClick: () => {
navigator.clipboard.writeText(node.path);
}
});
return items;
};
const handleNodeClick = (node: TreeNode) => {
onSelectFile?.(node.path);
toggleNode(node.path);
if (node.type === 'folder') {
setInternalSelectedPath(node.path);
onSelectFile?.(node.path);
toggleNode(node.path);
} else {
setInternalSelectedPath(node.path);
const extension = node.name.includes('.') ? node.name.split('.').pop() : undefined;
messageHub?.publish('asset-file:selected', {
fileInfo: {
name: node.name,
path: node.path,
extension,
size: node.size,
modified: node.modified,
isDirectory: false
}
});
}
};
const handleNodeDoubleClick = async (node: TreeNode) => {
if (node.type === 'file') {
if (fileActionRegistry) {
const handled = await fileActionRegistry.handleDoubleClick(node.path);
if (handled) {
return;
}
}
try {
await TauriAPI.openFileWithSystemApp(node.path);
} catch (error) {
console.error('Failed to open file:', error);
}
}
};
const handleContextMenu = (e: React.MouseEvent, node: TreeNode | null) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
position: { x: e.clientX, y: e.clientY },
node
});
};
const renderNode = (node: TreeNode, level: number = 0) => {
const isSelected = selectedPath === node.path;
const isSelected = (internalSelectedPath || selectedPath) === node.path;
const isRenaming = renamingNode === node.path;
const indent = level * 16;
return (
@@ -129,17 +557,46 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
<div
className={`tree-node ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${indent}px` }}
onClick={() => handleNodeClick(node)}
onClick={() => !isRenaming && handleNodeClick(node)}
onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)}
onContextMenu={(e) => handleContextMenu(e, node)}
>
<span className="tree-arrow">
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{node.type === 'folder' ? (
node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />
) : (
<span style={{ width: '14px', display: 'inline-block' }} />
)}
</span>
<span className="tree-icon">
<Folder size={16} />
{node.type === 'folder' ? (
<Folder size={16} style={{ color: '#ffa726' }} />
) : (
<File size={16} style={{ color: '#90caf9' }} />
)}
</span>
<span className="tree-label">{node.name}</span>
{isRenaming ? (
<input
type="text"
className="tree-rename-input"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onBlur={() => handleRename(node)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRename(node);
} else if (e.key === 'Escape') {
setRenamingNode(null);
}
}}
autoFocus
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="tree-label">{node.name}</span>
)}
</div>
{node.expanded && node.children && (
{node.type === 'folder' && node.expanded && node.children && (
<div className="tree-children">
{node.children.map((child) => renderNode(child, level + 1))}
</div>
@@ -157,8 +614,78 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
}
return (
<div className="file-tree">
{tree.map((node) => renderNode(node))}
</div>
<>
<div className="file-tree-toolbar">
<button
className="file-tree-toolbar-btn"
onClick={expandAll}
title="展开全部文件夹"
>
<ChevronsDown size={14} />
</button>
<button
className="file-tree-toolbar-btn"
onClick={collapseAll}
title="收缩全部文件夹"
>
<ChevronsUp size={14} />
</button>
</div>
<div
className="file-tree"
onContextMenu={(e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('file-tree')) {
handleContextMenu(e, null);
}
}}
>
{filteredTree.map((node) => renderNode(node))}
</div>
{contextMenu && (
<ContextMenu
items={getContextMenuItems(contextMenu.node)}
position={contextMenu.position}
onClose={() => setContextMenu(null)}
/>
)}
{deleteDialog && (
<ConfirmDialog
title="确认删除"
message={
deleteDialog.node.type === 'folder'
? `确定要删除文件夹 "${deleteDialog.node.name}" 及其所有内容吗?\n此操作无法撤销。`
: `确定要删除文件 "${deleteDialog.node.name}" 吗?\n此操作无法撤销。`
}
confirmText="删除"
cancelText="取消"
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteDialog(null)}
/>
)}
{promptDialog && (
<PromptDialog
title={
promptDialog.type === 'create-file' ? '新建文件' :
promptDialog.type === 'create-folder' ? '新建文件夹' :
'新建文件'
}
message={
promptDialog.type === 'create-file' ? '请输入文件名:' :
promptDialog.type === 'create-folder' ? '请输入文件夹名:' :
`请输入文件名 (将自动添加 .${promptDialog.templateExtension} 扩展名):`
}
placeholder={
promptDialog.type === 'create-file' ? '例如: config.json' :
promptDialog.type === 'create-folder' ? '例如: assets' :
'例如: MyFile'
}
confirmText="创建"
cancelText="取消"
onConfirm={handlePromptConfirm}
onCancel={() => setPromptDialog(null)}
/>
)}
</>
);
}

View File

@@ -17,17 +17,12 @@ interface FlexLayoutDockContainerProps {
export function FlexLayoutDockContainer({ panels, onPanelClose }: FlexLayoutDockContainerProps) {
const createDefaultLayout = useCallback((): IJsonModel => {
const leftPanels = panels.filter((p) => p.id.includes('hierarchy'));
const hierarchyPanels = panels.filter((p) => p.id.includes('hierarchy'));
const assetPanels = panels.filter((p) => p.id.includes('asset'));
const rightPanels = panels.filter((p) => p.id.includes('inspector'));
const bottomPanels = panels.filter((p) => p.id.includes('console') || p.id.includes('asset'))
.sort((a, b) => {
// 控制台排在前面
if (a.id.includes('console')) return -1;
if (b.id.includes('console')) return 1;
return 0;
});
const bottomPanels = panels.filter((p) => p.id.includes('console'));
const centerPanels = panels.filter((p) =>
!leftPanels.includes(p) && !rightPanels.includes(p) && !bottomPanels.includes(p)
!hierarchyPanels.includes(p) && !assetPanels.includes(p) && !rightPanels.includes(p) && !bottomPanels.includes(p)
);
// Build center column children
@@ -61,17 +56,43 @@ export function FlexLayoutDockContainer({ panels, onPanelClose }: FlexLayoutDock
// Build main row children
const mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
if (leftPanels.length > 0) {
// 左侧列:场景层级和资产面板垂直排列(五五分)
if (hierarchyPanels.length > 0 || assetPanels.length > 0) {
const leftColumnChildren: IJsonTabSetNode[] = [];
if (hierarchyPanels.length > 0) {
leftColumnChildren.push({
type: 'tabset',
weight: 50,
children: hierarchyPanels.map((p) => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false
}))
});
}
if (assetPanels.length > 0) {
leftColumnChildren.push({
type: 'tabset',
weight: 50,
children: assetPanels.map((p) => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false
}))
});
}
mainRowChildren.push({
type: 'tabset',
type: 'row',
weight: 20,
children: leftPanels.map((p) => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false
}))
children: leftColumnChildren
});
}
if (centerColumnChildren.length > 0) {

View File

@@ -0,0 +1,646 @@
import { useState, useEffect, useMemo } from 'react';
import { Entity } from '@esengine/ecs-framework';
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
import { PropertyInspector } from './PropertyInspector';
import { BehaviorTreeNodeProperties } from './BehaviorTreeNodeProperties';
import { FileSearch, ChevronDown, ChevronRight, X, Settings, Box, AlertTriangle, Copy, File as FileIcon, Folder, Clock, HardDrive } from 'lucide-react';
import { BehaviorTreeNode, useBehaviorTreeStore } from '../stores/behaviorTreeStore';
import { ICON_MAP } from '../presentation/config/editorConstants';
import { useNodeOperations } from '../presentation/hooks/useNodeOperations';
import { useCommandHistory } from '../presentation/hooks/useCommandHistory';
import { NodeFactory } from '../infrastructure/factories/NodeFactory';
import { BehaviorTreeValidator } from '../infrastructure/validation/BehaviorTreeValidator';
import { TauriAPI } from '../api/tauri';
import '../styles/EntityInspector.css';
interface InspectorProps {
entityStore: EntityStoreService;
messageHub: MessageHub;
projectPath?: string | null;
isExecuting?: boolean;
executionMode?: 'idle' | 'running' | 'paused' | 'step';
}
interface AssetFileInfo {
name: string;
path: string;
extension?: string;
size?: number;
modified?: number;
isDirectory: boolean;
}
type InspectorTarget =
| { type: 'entity'; data: Entity }
| { type: 'remote-entity'; data: any; details?: any }
| { type: 'behavior-tree-node'; data: BehaviorTreeNode }
| { type: 'asset-file'; data: AssetFileInfo; content?: string }
| null;
export function Inspector({ entityStore: _entityStore, messageHub, projectPath, isExecuting, executionMode }: InspectorProps) {
const [target, setTarget] = useState<InspectorTarget>(null);
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
const [componentVersion, setComponentVersion] = useState(0);
// 行为树节点操作相关
const nodeFactory = useMemo(() => new NodeFactory(), []);
const validator = useMemo(() => new BehaviorTreeValidator(), []);
const { commandManager } = useCommandHistory();
const nodeOperations = useNodeOperations(nodeFactory, validator, commandManager);
const { nodes, connections, isExecuting: storeIsExecuting } = useBehaviorTreeStore();
// 优先使用传入的 isExecuting否则使用 store 中的
const isRunning = isExecuting ?? storeIsExecuting;
// 当节点数据更新时,同步更新 target 中的节点
useEffect(() => {
if (target?.type === 'behavior-tree-node') {
const updatedNode = nodes.find(n => n.id === target.data.id);
if (updatedNode) {
const currentDataStr = JSON.stringify(target.data.data);
const updatedDataStr = JSON.stringify(updatedNode.data);
if (currentDataStr !== updatedDataStr) {
setTarget({ type: 'behavior-tree-node', data: updatedNode });
}
}
}
}, [nodes]);
useEffect(() => {
const handleEntitySelection = (data: { entity: Entity | null }) => {
if (data.entity) {
setTarget({ type: 'entity', data: data.entity });
} else {
setTarget(null);
}
setComponentVersion(0);
};
const handleRemoteEntitySelection = (data: { entity: any }) => {
setTarget({ type: 'remote-entity', data: data.entity });
};
const handleEntityDetails = (event: Event) => {
const customEvent = event as CustomEvent;
const details = customEvent.detail;
if (target?.type === 'remote-entity') {
setTarget({ ...target, details });
}
};
const handleBehaviorTreeNodeSelection = (data: { node: BehaviorTreeNode }) => {
setTarget({ type: 'behavior-tree-node', data: data.node });
};
const handleAssetFileSelection = async (data: { fileInfo: AssetFileInfo }) => {
const fileInfo = data.fileInfo;
if (fileInfo.isDirectory) {
setTarget({ type: 'asset-file', data: fileInfo });
return;
}
const textExtensions = ['txt', 'json', 'md', 'ts', 'tsx', 'js', 'jsx', 'css', 'html', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'log', 'btree', 'ecs'];
const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase());
if (isTextFile) {
try {
const content = await TauriAPI.readFileContent(fileInfo.path);
setTarget({ type: 'asset-file', data: fileInfo, content });
} catch (error) {
console.error('Failed to read file content:', error);
setTarget({ type: 'asset-file', data: fileInfo });
}
} else {
setTarget({ type: 'asset-file', data: fileInfo });
}
};
const handleComponentChange = () => {
setComponentVersion((prev) => prev + 1);
};
const unsubEntitySelect = messageHub.subscribe('entity:selected', handleEntitySelection);
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection);
const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleBehaviorTreeNodeSelection);
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
window.addEventListener('profiler:entity-details', handleEntityDetails);
return () => {
unsubEntitySelect();
unsubRemoteSelect();
unsubNodeSelect();
unsubAssetFileSelect();
unsubComponentAdded();
unsubComponentRemoved();
window.removeEventListener('profiler:entity-details', handleEntityDetails);
};
}, [messageHub, target]);
const handleRemoveComponent = (index: number) => {
if (target?.type !== 'entity') return;
const entity = target.data;
const component = entity.components[index];
if (component) {
entity.removeComponent(component);
messageHub.publish('component:removed', { entity, component });
}
};
const toggleComponentExpanded = (index: number) => {
setExpandedComponents((prev) => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
return newSet;
});
};
const handlePropertyChange = (component: any, propertyName: string, value: any) => {
if (target?.type !== 'entity') return;
const entity = target.data;
messageHub.publish('component:property:changed', {
entity,
component,
propertyName,
value
});
};
const handleNodePropertyChange = (propertyName: string, value: any) => {
if (target?.type !== 'behavior-tree-node') return;
const node = target.data;
nodeOperations.updateNodeData(node.id, {
...node.data,
[propertyName]: value
});
};
const handleCopyNodeInfo = () => {
if (target?.type !== 'behavior-tree-node') return;
const node = target.data;
const childrenInfo = node.children.map((childId, index) => {
const childNode = nodes.find(n => n.id === childId);
return ` ${index + 1}. ${childNode?.template.displayName || '未知'} (ID: ${childId})`;
}).join('\n');
const incomingConnections = connections.filter(conn => conn.to === node.id);
const outgoingConnections = connections.filter(conn => conn.from === node.id);
const connectionInfo = [
incomingConnections.length > 0 ? `输入连接: ${incomingConnections.length}` : '',
...incomingConnections.map(conn => {
const fromNode = nodes.find(n => n.id === conn.from);
return ` 来自: ${fromNode?.template.displayName || '未知'} (${conn.from})`;
}),
outgoingConnections.length > 0 ? `输出连接: ${outgoingConnections.length}` : '',
...outgoingConnections.map(conn => {
const toNode = nodes.find(n => n.id === conn.to);
return ` 到: ${toNode?.template.displayName || '未知'} (${conn.to})`;
})
].filter(Boolean).join('\n');
const nodeInfo = `
节点信息
========
名称: ${node.template.displayName}
类型: ${node.template.type}
分类: ${node.template.category}
类名: ${node.template.className || '无'}
节点ID: ${node.id}
子节点 (${node.children.length}个):
${childrenInfo || ' 无'}
连接信息:
${connectionInfo || ' 无连接'}
属性数据:
${JSON.stringify(node.data, null, 2)}
`.trim();
navigator.clipboard.writeText(nodeInfo).then(() => {
messageHub.publish('notification:show', {
type: 'success',
message: '节点信息已复制到剪贴板'
});
}).catch(() => {
const textarea = document.createElement('textarea');
textarea.value = nodeInfo;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
messageHub.publish('notification:show', {
type: 'success',
message: '节点信息已复制到剪贴板'
});
});
};
const renderRemoteProperty = (key: string, value: any) => {
if (value === null || value === undefined) {
return (
<div key={key} className="property-field">
<label className="property-label">{key}</label>
<span className="property-value-text">null</span>
</div>
);
}
if (typeof value === 'object' && !Array.isArray(value)) {
return (
<div key={key} className="property-field">
<label className="property-label">{key}</label>
<div className="property-value-object">
{Object.entries(value).map(([subKey, subValue]) => (
<div key={subKey} className="property-subfield">
<span className="property-sublabel">{subKey}:</span>
<span className="property-value-text">{String(subValue)}</span>
</div>
))}
</div>
</div>
);
}
return (
<div key={key} className="property-field">
<label className="property-label">{key}</label>
<span className="property-value-text">{String(value)}</span>
</div>
);
};
const formatFileSize = (bytes?: number): string => {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
const formatDate = (timestamp?: number): string => {
if (!timestamp) return '未知';
const date = new Date(timestamp * 1000);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const renderAssetFile = (fileInfo: AssetFileInfo, content?: string) => {
const IconComponent = fileInfo.isDirectory ? Folder : FileIcon;
const iconColor = fileInfo.isDirectory ? '#dcb67a' : '#90caf9';
return (
<div className="entity-inspector">
<div className="inspector-header">
<IconComponent size={16} style={{ color: iconColor }} />
<span className="entity-name">{fileInfo.name}</span>
</div>
<div className="inspector-content">
<div className="inspector-section">
<div className="section-title"></div>
<div className="property-field">
<label className="property-label"></label>
<span className="property-value-text">
{fileInfo.isDirectory ? '文件夹' : fileInfo.extension ? `.${fileInfo.extension}` : '文件'}
</span>
</div>
{fileInfo.size !== undefined && !fileInfo.isDirectory && (
<div className="property-field">
<label className="property-label"><HardDrive size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} /></label>
<span className="property-value-text">{formatFileSize(fileInfo.size)}</span>
</div>
)}
{fileInfo.modified !== undefined && (
<div className="property-field">
<label className="property-label"><Clock size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} /></label>
<span className="property-value-text">{formatDate(fileInfo.modified)}</span>
</div>
)}
<div className="property-field">
<label className="property-label"></label>
<span className="property-value-text" style={{
fontFamily: 'Consolas, Monaco, monospace',
fontSize: '11px',
color: '#666',
wordBreak: 'break-all'
}}>
{fileInfo.path}
</span>
</div>
</div>
{content && (
<div className="inspector-section">
<div className="section-title"></div>
<div className="file-preview-content">
{content}
</div>
</div>
)}
{!content && !fileInfo.isDirectory && (
<div className="inspector-section">
<div style={{
padding: '20px',
textAlign: 'center',
color: '#666',
fontSize: '13px'
}}>
</div>
</div>
)}
</div>
</div>
);
};
const renderBehaviorTreeNode = (node: BehaviorTreeNode) => {
const IconComponent = node.template.icon ? (ICON_MAP as any)[node.template.icon] : Box;
return (
<div className="entity-inspector">
<div className="inspector-header">
{IconComponent && <IconComponent size={16} style={{ color: node.template.color || '#999' }} />}
<span className="entity-name">{node.template.displayName || '未命名节点'}</span>
<button
onClick={handleCopyNodeInfo}
style={{
marginLeft: 'auto',
background: 'transparent',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '4px',
padding: '4px 8px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
color: '#999',
fontSize: '12px'
}}
title="复制节点信息"
>
<Copy size={14} />
<span></span>
</button>
</div>
{isRunning && (
<div style={{
padding: '10px 14px',
backgroundColor: 'rgba(255, 152, 0, 0.1)',
borderLeft: '3px solid #ff9800',
margin: '12px',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '10px',
fontSize: '12px',
color: '#ff9800',
lineHeight: '1.4'
}}>
<AlertTriangle size={16} style={{ flexShrink: 0 }} />
<span></span>
</div>
)}
<div className="inspector-content">
<div className="inspector-section">
<div className="section-title"></div>
<div className="property-field">
<label className="property-label"></label>
<span className="property-value-text">{node.template.type}</span>
</div>
<div className="property-field">
<label className="property-label"></label>
<span className="property-value-text">{node.template.category}</span>
</div>
{node.template.description && (
<div className="property-field">
<label className="property-label"></label>
<span className="property-value-text" style={{ color: '#999' }}>{node.template.description}</span>
</div>
)}
{node.template.className && (
<div className="property-field">
<label className="property-label"></label>
<span className="property-value-text" style={{ fontFamily: 'Consolas, Monaco, monospace', color: '#0e639c' }}>
{node.template.className}
</span>
</div>
)}
</div>
{node.template.properties && node.template.properties.length > 0 && (
<div className="inspector-section">
<div className="section-title"></div>
<BehaviorTreeNodeProperties
key={node.id}
selectedNode={node}
onPropertyChange={handleNodePropertyChange}
projectPath={projectPath}
/>
</div>
)}
{node.children.length > 0 && (
<div className="inspector-section">
<div className="section-title"> ({node.children.length})</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{node.children.map((childId, index) => {
const childNode = nodes.find(n => n.id === childId);
const ChildIcon = childNode?.template.icon ? (ICON_MAP as any)[childNode.template.icon] : Box;
return (
<div
key={childId}
className="child-node-item"
style={{
borderLeft: `3px solid ${childNode?.template.color || '#666'}`
}}
>
<span className="child-node-index">{index + 1}.</span>
{childNode && ChildIcon && (
<ChildIcon size={14} style={{ color: childNode.template.color || '#999', flexShrink: 0 }} />
)}
<span className="child-node-name">
{childNode?.template.displayName || childId}
</span>
</div>
);
})}
</div>
</div>
)}
<div className="inspector-section">
<div className="section-title"></div>
<div className="property-field">
<label className="property-label">ID</label>
<span className="property-value-text" style={{ fontFamily: 'Consolas, Monaco, monospace', color: '#666', fontSize: '11px' }}>
{node.id}
</span>
</div>
<div className="property-field">
<label className="property-label"></label>
<span className="property-value-text" style={{ color: '#999' }}>
({node.position.x.toFixed(0)}, {node.position.y.toFixed(0)})
</span>
</div>
</div>
</div>
</div>
);
};
if (!target) {
return (
<div className="entity-inspector">
<div className="empty-inspector">
<FileSearch size={48} style={{ color: '#555', marginBottom: '16px' }} />
<div style={{ color: '#999', fontSize: '14px' }}></div>
<div style={{ color: '#666', fontSize: '12px', marginTop: '8px' }}>
</div>
</div>
</div>
);
}
if (target.type === 'behavior-tree-node') {
return renderBehaviorTreeNode(target.data);
}
if (target.type === 'asset-file') {
return renderAssetFile(target.data, target.content);
}
if (target.type === 'remote-entity') {
const entity = target.data;
const details = (target as any).details;
return (
<div className="entity-inspector">
<div className="inspector-header">
<Settings size={16} />
<span className="entity-name"> #{entity.id}</span>
</div>
<div className="inspector-content">
<div className="inspector-section">
<div className="section-title"></div>
<div className="property-field">
<label className="property-label">Entity ID</label>
<span className="property-value-text">{entity.id}</span>
</div>
<div className="property-field">
<label className="property-label">Enabled</label>
<span className="property-value-text">{entity.enabled ? 'true' : 'false'}</span>
</div>
{entity.name && (
<div className="property-field">
<label className="property-label">Name</label>
<span className="property-value-text">{entity.name}</span>
</div>
)}
</div>
{details && (
<div className="inspector-section">
<div className="section-title"></div>
{Object.entries(details).map(([key, value]) => renderRemoteProperty(key, value))}
</div>
)}
</div>
</div>
);
}
if (target.type === 'entity') {
const entity = target.data;
return (
<div className="entity-inspector">
<div className="inspector-header">
<Settings size={16} />
<span className="entity-name">{entity.name || `Entity #${entity.id}`}</span>
</div>
<div className="inspector-content">
<div className="inspector-section">
<div className="section-title"></div>
<div className="property-field">
<label className="property-label">Entity ID</label>
<span className="property-value-text">{entity.id}</span>
</div>
<div className="property-field">
<label className="property-label">Enabled</label>
<span className="property-value-text">{entity.enabled ? 'true' : 'false'}</span>
</div>
</div>
{entity.components.length > 0 && (
<div className="inspector-section">
<div className="section-title"></div>
{entity.components.map((component: any, index: number) => {
const isExpanded = expandedComponents.has(index);
const componentName = component.constructor?.name || 'Component';
return (
<div key={`${componentName}-${index}-${componentVersion}`} className="component-item">
<div className="component-header" onClick={() => toggleComponentExpanded(index)}>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="component-name">{componentName}</span>
<button
className="component-remove-btn"
onClick={(e) => {
e.stopPropagation();
handleRemoveComponent(index);
}}
title="移除组件"
>
<X size={12} />
</button>
</div>
{isExpanded && (
<div className="component-properties">
<PropertyInspector
component={component}
onChange={(propName: string, value: any) => handlePropertyChange(component, propName, value)}
/>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,88 @@
import { useState, useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import '../styles/PromptDialog.css';
interface PromptDialogProps {
title: string;
message: string;
defaultValue?: string;
placeholder?: string;
confirmText: string;
cancelText: string;
onConfirm: (value: string) => void;
onCancel: () => void;
}
export function PromptDialog({
title,
message,
defaultValue = '',
placeholder,
confirmText,
cancelText,
onConfirm,
onCancel
}: PromptDialogProps) {
const [value, setValue] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, []);
const handleConfirm = () => {
if (value.trim()) {
onConfirm(value.trim());
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
return (
<div className="prompt-dialog-overlay" onClick={onCancel}>
<div className="prompt-dialog" onClick={(e) => e.stopPropagation()}>
<div className="prompt-dialog-header">
<h2>{title}</h2>
<button className="close-btn" onClick={onCancel}>
<X size={16} />
</button>
</div>
<div className="prompt-dialog-content">
<p>{message}</p>
<input
ref={inputRef}
type="text"
className="prompt-dialog-input"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
/>
</div>
<div className="prompt-dialog-footer">
<button className="prompt-dialog-btn cancel" onClick={onCancel}>
{cancelText}
</button>
<button
className="prompt-dialog-btn confirm"
onClick={handleConfirm}
disabled={!value.trim()}
>
{confirmText}
</button>
</div>
</div>
</div>
);
}