Feature/ecs behavior tree (#188)
* feat(behavior-tree): 完全 ECS 化的行为树系统 * feat(editor-app): 添加行为树可视化编辑器 * chore: 移除 Cocos Creator 扩展目录 * feat(editor-app): 行为树编辑器功能增强 * fix(editor-app): 修复 TypeScript 类型错误 * feat(editor-app): 使用 FlexLayout 重构面板系统并优化资产浏览器 * feat(editor-app): 改进编辑器UI样式并修复行为树执行顺序 * feat(behavior-tree,editor-app): 添加装饰器系统并优化编辑器性能 * feat(behavior-tree,editor-app): 添加属性绑定系统 * feat(editor-app,behavior-tree): 优化编辑器UI并改进行为树功能 * feat(editor-app,behavior-tree): 添加全局黑板系统并增强资产浏览器功能 * feat(behavior-tree,editor-app): 添加运行时资产导出系统 * feat(behavior-tree,editor-app): 添加SubTree系统和资产选择器 * feat(behavior-tree,editor-app): 优化系统架构并改进编辑器文件管理 * fix(behavior-tree,editor-app): 修复SubTree节点错误显示空节点警告 * fix(editor-app): 修复局部黑板类型定义文件扩展名错误
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3 } from 'lucide-react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub } from '@esengine/editor-core';
|
||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||
import { FileTree } from './FileTree';
|
||||
import { ResizablePanel } from './ResizablePanel';
|
||||
import { ContextMenu, ContextMenuItem } from './ContextMenu';
|
||||
import '../styles/AssetBrowser.css';
|
||||
|
||||
interface AssetItem {
|
||||
@@ -17,39 +19,38 @@ interface AssetBrowserProps {
|
||||
projectPath: string | null;
|
||||
locale: string;
|
||||
onOpenScene?: (scenePath: string) => void;
|
||||
onOpenBehaviorTree?: (btreePath: string) => void;
|
||||
}
|
||||
|
||||
type ViewMode = 'tree-split' | 'tree-only';
|
||||
|
||||
export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('tree-split');
|
||||
export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorTree }: AssetBrowserProps) {
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [assets, setAssets] = useState<AssetItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
position: { x: number; y: number };
|
||||
asset: AssetItem;
|
||||
} | null>(null);
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
title: 'Assets',
|
||||
title: 'Content Browser',
|
||||
noProject: 'No project loaded',
|
||||
loading: 'Loading...',
|
||||
empty: 'No assets found',
|
||||
search: 'Search...',
|
||||
viewTreeSplit: 'Tree + List',
|
||||
viewTreeOnly: 'Tree Only',
|
||||
name: 'Name',
|
||||
type: 'Type',
|
||||
file: 'File',
|
||||
folder: 'Folder'
|
||||
},
|
||||
zh: {
|
||||
title: '资产',
|
||||
title: '内容浏览器',
|
||||
noProject: '没有加载项目',
|
||||
loading: '加载中...',
|
||||
empty: '没有找到资产',
|
||||
search: '搜索...',
|
||||
viewTreeSplit: '树形+列表',
|
||||
viewTreeOnly: '纯树形',
|
||||
name: '名称',
|
||||
type: '类型',
|
||||
file: '文件',
|
||||
@@ -61,14 +62,14 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
|
||||
useEffect(() => {
|
||||
if (projectPath) {
|
||||
if (viewMode === 'tree-split') {
|
||||
loadAssets(projectPath);
|
||||
}
|
||||
setCurrentPath(projectPath);
|
||||
loadAssets(projectPath);
|
||||
} else {
|
||||
setAssets([]);
|
||||
setCurrentPath(null);
|
||||
setSelectedPath(null);
|
||||
}
|
||||
}, [projectPath, viewMode]);
|
||||
}, [projectPath]);
|
||||
|
||||
// Listen for asset reveal requests
|
||||
useEffect(() => {
|
||||
@@ -79,19 +80,17 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
const filePath = data.path;
|
||||
if (filePath) {
|
||||
setSelectedPath(filePath);
|
||||
|
||||
if (viewMode === 'tree-split') {
|
||||
const lastSlashIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
||||
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : null;
|
||||
if (dirPath) {
|
||||
loadAssets(dirPath);
|
||||
}
|
||||
const lastSlashIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
||||
const dirPath = lastSlashIndex > 0 ? filePath.substring(0, lastSlashIndex) : null;
|
||||
if (dirPath) {
|
||||
setCurrentPath(dirPath);
|
||||
loadAssets(dirPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [viewMode]);
|
||||
}, []);
|
||||
|
||||
const loadAssets = async (path: string) => {
|
||||
setLoading(true);
|
||||
@@ -110,7 +109,10 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
};
|
||||
});
|
||||
|
||||
setAssets(assetItems);
|
||||
setAssets(assetItems.sort((a, b) => {
|
||||
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||
return a.type === 'folder' ? -1 : 1;
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to load assets:', error);
|
||||
setAssets([]);
|
||||
@@ -119,69 +121,154 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
}
|
||||
};
|
||||
|
||||
const handleTreeSelect = (path: string) => {
|
||||
setSelectedPath(path);
|
||||
if (viewMode === 'tree-split') {
|
||||
loadAssets(path);
|
||||
}
|
||||
const handleFolderSelect = (path: string) => {
|
||||
setCurrentPath(path);
|
||||
loadAssets(path);
|
||||
};
|
||||
|
||||
const handleAssetClick = (asset: AssetItem) => {
|
||||
setSelectedPath(asset.path);
|
||||
};
|
||||
|
||||
const handleAssetDoubleClick = (asset: AssetItem) => {
|
||||
if (asset.type === 'file' && asset.extension === 'ecs') {
|
||||
if (onOpenScene) {
|
||||
const handleAssetDoubleClick = async (asset: AssetItem) => {
|
||||
if (asset.type === 'folder') {
|
||||
setCurrentPath(asset.path);
|
||||
loadAssets(asset.path);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, asset: AssetItem) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
position: { x: e.clientX, y: e.clientY },
|
||||
asset
|
||||
});
|
||||
};
|
||||
|
||||
const getContextMenuItems = (asset: AssetItem): ContextMenuItem[] => {
|
||||
const items: ContextMenuItem[] = [];
|
||||
|
||||
// 打开
|
||||
if (asset.type === 'file') {
|
||||
items.push({
|
||||
label: locale === 'zh' ? '打开' : 'Open',
|
||||
icon: <File size={16} />,
|
||||
onClick: () => handleAssetDoubleClick(asset)
|
||||
});
|
||||
}
|
||||
|
||||
// 在文件管理器中显示
|
||||
items.push({
|
||||
label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer',
|
||||
icon: <FolderOpen size={16} />,
|
||||
onClick: async () => {
|
||||
try {
|
||||
await TauriAPI.showInFolder(asset.path);
|
||||
} catch (error) {
|
||||
console.error('Failed to show in folder:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 复制路径
|
||||
items.push({
|
||||
label: locale === 'zh' ? '复制路径' : 'Copy Path',
|
||||
icon: <Copy size={16} />,
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(asset.path);
|
||||
}
|
||||
});
|
||||
|
||||
items.push({ label: '', separator: true, onClick: () => {} });
|
||||
|
||||
// 重命名
|
||||
items.push({
|
||||
label: locale === 'zh' ? '重命名' : 'Rename',
|
||||
icon: <Edit3 size={16} />,
|
||||
onClick: () => {
|
||||
// TODO: 实现重命名功能
|
||||
console.log('Rename:', asset.path);
|
||||
},
|
||||
disabled: true
|
||||
});
|
||||
|
||||
// 删除
|
||||
items.push({
|
||||
label: locale === 'zh' ? '删除' : 'Delete',
|
||||
icon: <Trash2 size={16} />,
|
||||
onClick: () => {
|
||||
// TODO: 实现删除功能
|
||||
console.log('Delete:', asset.path);
|
||||
},
|
||||
disabled: true
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
const getBreadcrumbs = () => {
|
||||
if (!currentPath || !projectPath) return [];
|
||||
|
||||
const relative = currentPath.replace(projectPath, '');
|
||||
const parts = relative.split(/[/\\]/).filter(p => p);
|
||||
|
||||
const crumbs = [{ name: 'Content', path: projectPath }];
|
||||
let accPath = projectPath;
|
||||
|
||||
for (const part of parts) {
|
||||
accPath = `${accPath}${accPath.endsWith('\\') || accPath.endsWith('/') ? '' : '/'}${part}`;
|
||||
crumbs.push({ name: part, path: accPath });
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
};
|
||||
|
||||
const filteredAssets = searchQuery
|
||||
? assets.filter(asset =>
|
||||
asset.type === 'file' && asset.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
asset.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: assets.filter(asset => asset.type === 'file');
|
||||
: assets;
|
||||
|
||||
const getFileIcon = (extension?: string) => {
|
||||
switch (extension?.toLowerCase()) {
|
||||
const getFileIcon = (asset: AssetItem) => {
|
||||
if (asset.type === 'folder') {
|
||||
return <Folder className="asset-icon" style={{ color: '#ffa726' }} size={20} />;
|
||||
}
|
||||
|
||||
const ext = asset.extension?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'ecs':
|
||||
return <File className="asset-icon" style={{ color: '#66bb6a' }} size={20} />;
|
||||
case 'btree':
|
||||
return <FileText className="asset-icon" style={{ color: '#ab47bc' }} size={20} />;
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="asset-icon">
|
||||
<path d="M14 2H6C4.89543 2 4 2.89543 4 4V20C4 21.1046 4.89543 22 6 22H18C19.1046 22 20 21.1046 20 20V8L14 2Z" strokeWidth="2"/>
|
||||
<path d="M14 2V8H20" strokeWidth="2"/>
|
||||
<path d="M12 18L12 14M12 10L12 12" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
return <FileCode className="asset-icon" style={{ color: '#42a5f5' }} size={20} />;
|
||||
case 'json':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="asset-icon">
|
||||
<path d="M14 2H6C4.89543 2 4 2.89543 4 4V20C4 21.1046 4.89543 22 6 22H18C19.1046 22 20 21.1046 20 20V8L14 2Z" strokeWidth="2"/>
|
||||
<path d="M14 2V8H20" strokeWidth="2"/>
|
||||
</svg>
|
||||
);
|
||||
return <FileJson className="asset-icon" style={{ color: '#ffa726' }} size={20} />;
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'gif':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="asset-icon">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" strokeWidth="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor"/>
|
||||
<path d="M21 15L16 10L5 21" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
return <FileImage className="asset-icon" style={{ color: '#ec407a' }} size={20} />;
|
||||
default:
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="asset-icon">
|
||||
<path d="M14 2H6C4.89543 2 4 2.89543 4 4V20C4 21.1046 4.89543 22 6 22H18C19.1046 22 20 21.1046 20 20V8L14 2Z" strokeWidth="2"/>
|
||||
<path d="M14 2V8H20" strokeWidth="2"/>
|
||||
</svg>
|
||||
);
|
||||
return <File className="asset-icon" size={20} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -198,114 +285,96 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
|
||||
);
|
||||
}
|
||||
|
||||
const renderListView = () => (
|
||||
<div className="asset-browser-list">
|
||||
<div className="asset-browser-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="asset-search"
|
||||
placeholder={t.search}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="asset-browser-loading">
|
||||
<p>{t.loading}</p>
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="asset-browser-empty">
|
||||
<p>{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)}
|
||||
>
|
||||
{getFileIcon(asset.extension)}
|
||||
<div className="asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="asset-type">
|
||||
{asset.extension || t.file}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const breadcrumbs = getBreadcrumbs();
|
||||
|
||||
return (
|
||||
<div className="asset-browser">
|
||||
<div className="asset-browser-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<h3 style={{ margin: 0 }}>{t.title}</h3>
|
||||
<div className="view-mode-buttons">
|
||||
<button
|
||||
className={`view-mode-btn ${viewMode === 'tree-split' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('tree-split')}
|
||||
title={t.viewTreeSplit}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="7" height="18"/>
|
||||
<rect x="14" y="3" width="7" height="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`view-mode-btn ${viewMode === 'tree-only' ? 'active' : ''}`}
|
||||
onClick={() => setViewMode('tree-only')}
|
||||
title={t.viewTreeOnly}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h3>{t.title}</h3>
|
||||
</div>
|
||||
|
||||
<div className="asset-browser-content">
|
||||
{viewMode === 'tree-only' ? (
|
||||
<div className="asset-browser-tree-only">
|
||||
<div className="asset-browser-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="asset-search"
|
||||
placeholder={t.search}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
defaultSize={200}
|
||||
minSize={150}
|
||||
maxSize={400}
|
||||
leftOrTop={
|
||||
<div className="asset-browser-tree">
|
||||
<FileTree
|
||||
rootPath={projectPath}
|
||||
onSelectFile={handleFolderSelect}
|
||||
selectedPath={currentPath}
|
||||
/>
|
||||
</div>
|
||||
<FileTree
|
||||
rootPath={projectPath}
|
||||
onSelectFile={handleTreeSelect}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ResizablePanel
|
||||
direction="horizontal"
|
||||
defaultSize={200}
|
||||
minSize={150}
|
||||
maxSize={400}
|
||||
leftOrTop={
|
||||
<div className="asset-browser-tree">
|
||||
<FileTree
|
||||
rootPath={projectPath}
|
||||
onSelectFile={handleTreeSelect}
|
||||
selectedPath={selectedPath}
|
||||
}
|
||||
rightOrBottom={
|
||||
<div className="asset-browser-list">
|
||||
<div className="asset-browser-breadcrumb">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={crumb.path}>
|
||||
<span
|
||||
className="breadcrumb-item"
|
||||
onClick={() => {
|
||||
setCurrentPath(crumb.path);
|
||||
loadAssets(crumb.path);
|
||||
}}
|
||||
>
|
||||
{crumb.name}
|
||||
</span>
|
||||
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
|
||||
</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>
|
||||
}
|
||||
rightOrBottom={renderListView()}
|
||||
/>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="asset-browser-loading">
|
||||
<p>{t.loading}</p>
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="asset-browser-empty">
|
||||
<p>{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}
|
||||
</div>
|
||||
<div className="asset-type">
|
||||
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
items={getContextMenuItems(contextMenu.asset)}
|
||||
position={contextMenu.position}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user