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:
YHH
2025-10-27 09:29:11 +08:00
committed by GitHub
parent 0cd99209c4
commit 009f8af4e1
234 changed files with 21824 additions and 15295 deletions

View File

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