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

View File

@@ -0,0 +1,141 @@
import { useState, useEffect } from 'react';
import { RefreshCw, Folder } from 'lucide-react';
import { TauriAPI } from '../api/tauri';
interface AssetPickerProps {
value: string;
onChange: (value: string) => void;
projectPath: string | null;
filter?: 'btree' | 'ecs';
label?: string;
}
/**
* 资产选择器组件
* 用于选择项目中的资产文件
*/
export function AssetPicker({ value, onChange, projectPath, filter = 'btree', label }: AssetPickerProps) {
const [assets, setAssets] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (projectPath) {
loadAssets();
}
}, [projectPath]);
const loadAssets = async () => {
if (!projectPath) return;
setLoading(true);
try {
if (filter === 'btree') {
const btrees = await TauriAPI.scanBehaviorTrees(projectPath);
setAssets(btrees);
}
} catch (error) {
console.error('Failed to load assets:', error);
setAssets([]);
} finally {
setLoading(false);
}
};
const handleBrowse = async () => {
try {
if (filter === 'btree') {
const path = await TauriAPI.openBehaviorTreeDialog();
if (path && projectPath) {
const behaviorsPath = `${projectPath}\\.ecs\\behaviors\\`.replace(/\\/g, '\\\\');
const relativePath = path.replace(behaviorsPath, '')
.replace(/\\/g, '/')
.replace('.btree', '');
onChange(relativePath);
await loadAssets();
}
}
} catch (error) {
console.error('Failed to browse asset:', error);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{label && (
<label style={{ fontSize: '11px', color: '#aaa', fontWeight: '500' }}>
{label}
</label>
)}
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={loading || !projectPath}
style={{
flex: 1,
padding: '4px 8px',
backgroundColor: '#1e1e1e',
border: '1px solid #3e3e42',
borderRadius: '3px',
color: '#cccccc',
fontSize: '12px',
cursor: loading || !projectPath ? 'not-allowed' : 'pointer'
}}
>
<option value="">{loading ? '加载中...' : '选择资产...'}</option>
{assets.map(asset => (
<option key={asset} value={asset}>
{asset}
</option>
))}
</select>
<button
onClick={loadAssets}
disabled={loading || !projectPath}
style={{
padding: '4px 8px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: loading || !projectPath ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
opacity: loading || !projectPath ? 0.5 : 1
}}
title="刷新资产列表"
>
<RefreshCw size={14} />
</button>
<button
onClick={handleBrowse}
disabled={loading || !projectPath}
style={{
padding: '4px 8px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: loading || !projectPath ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
opacity: loading || !projectPath ? 0.5 : 1
}}
title="浏览文件..."
>
<Folder size={14} />
</button>
</div>
{!projectPath && (
<div style={{ fontSize: '10px', color: '#ff6b6b', marginTop: '2px' }}>
</div>
)}
{value && assets.length > 0 && !assets.includes(value) && (
<div style={{ fontSize: '10px', color: '#ffa726', marginTop: '2px' }}>
警告: 资产 "{value}"
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,339 @@
import { useState, useEffect } from 'react';
import { X, Folder, File, Search, ArrowLeft, Grid, List, FileCode } from 'lucide-react';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import '../styles/AssetPickerDialog.css';
interface AssetPickerDialogProps {
projectPath: string;
fileExtension: string;
onSelect: (assetId: string) => void;
onClose: () => void;
locale: string;
/** 资产基础路径(相对于项目根目录),用于计算 assetId */
assetBasePath?: string;
}
interface AssetItem {
name: string;
path: string;
isDir: boolean;
extension?: string;
size?: number;
modified?: number;
}
type ViewMode = 'list' | 'grid';
export function AssetPickerDialog({ projectPath, fileExtension, onSelect, onClose, locale, assetBasePath }: AssetPickerDialogProps) {
// 计算实际的资产目录路径
const actualAssetPath = assetBasePath
? `${projectPath}/${assetBasePath}`.replace(/\\/g, '/').replace(/\/+/g, '/')
: projectPath;
const [currentPath, setCurrentPath] = useState(actualAssetPath);
const [assets, setAssets] = useState<AssetItem[]>([]);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('list');
const translations = {
en: {
title: 'Select Asset',
loading: 'Loading...',
empty: 'No assets found',
select: 'Select',
cancel: 'Cancel',
search: 'Search...',
back: 'Back',
listView: 'List View',
gridView: 'Grid View'
},
zh: {
title: '选择资产',
loading: '加载中...',
empty: '没有找到资产',
select: '选择',
cancel: '取消',
search: '搜索...',
back: '返回上级',
listView: '列表视图',
gridView: '网格视图'
}
};
const t = translations[locale as keyof typeof translations] || translations.en;
useEffect(() => {
loadAssets(currentPath);
}, [currentPath]);
const loadAssets = async (path: string) => {
setLoading(true);
try {
const entries = await TauriAPI.listDirectory(path);
const assetItems: AssetItem[] = entries
.map((entry: DirectoryEntry) => {
const extension = entry.is_dir ? undefined :
(entry.name.includes('.') ? entry.name.split('.').pop() : undefined);
return {
name: entry.name,
path: entry.path,
isDir: entry.is_dir,
extension,
size: entry.size,
modified: entry.modified
};
})
.filter(item => item.isDir || item.extension === fileExtension)
.sort((a, b) => {
if (a.isDir === b.isDir) return a.name.localeCompare(b.name);
return a.isDir ? -1 : 1;
});
setAssets(assetItems);
} catch (error) {
console.error('Failed to load assets:', error);
setAssets([]);
} finally {
setLoading(false);
}
};
// 过滤搜索结果
const filteredAssets = assets.filter(item =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
);
// 格式化文件大小
const formatFileSize = (bytes?: number): string => {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// 格式化修改时间
const formatDate = (timestamp?: number): string => {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
// 返回上级目录
const handleGoBack = () => {
const parentPath = currentPath.split(/[/\\]/).slice(0, -1).join('/');
const minPath = actualAssetPath.replace(/[/\\]$/, '');
if (parentPath && parentPath !== minPath) {
setCurrentPath(parentPath);
} else if (currentPath !== actualAssetPath) {
setCurrentPath(actualAssetPath);
}
};
// 只能返回到资产基础目录,不能再往上
const canGoBack = currentPath !== actualAssetPath;
const handleItemClick = (item: AssetItem) => {
if (item.isDir) {
setCurrentPath(item.path);
} else {
setSelectedPath(item.path);
}
};
const handleItemDoubleClick = (item: AssetItem) => {
if (!item.isDir) {
const assetId = calculateAssetId(item.path);
onSelect(assetId);
}
};
const handleSelect = () => {
if (selectedPath) {
const assetId = calculateAssetId(selectedPath);
onSelect(assetId);
}
};
/**
* 计算资产ID
* 将绝对路径转换为相对于资产基础目录的assetId不含扩展名
*/
const calculateAssetId = (absolutePath: string): string => {
const normalized = absolutePath.replace(/\\/g, '/');
const baseNormalized = actualAssetPath.replace(/\\/g, '/');
// 获取相对于资产基础目录的路径
let relativePath = normalized;
if (normalized.startsWith(baseNormalized)) {
relativePath = normalized.substring(baseNormalized.length);
}
// 移除开头的斜杠
relativePath = relativePath.replace(/^\/+/, '');
// 移除文件扩展名
const assetId = relativePath.replace(new RegExp(`\\.${fileExtension}$`), '');
return assetId;
};
const getBreadcrumbs = () => {
const basePathNormalized = actualAssetPath.replace(/\\/g, '/');
const currentPathNormalized = currentPath.replace(/\\/g, '/');
const relative = currentPathNormalized.replace(basePathNormalized, '');
const parts = relative.split('/').filter(p => p);
// 根路径名称(显示"行为树"或"Assets"
const rootName = assetBasePath
? assetBasePath.split('/').pop() || 'Assets'
: 'Content';
const crumbs = [{ name: rootName, path: actualAssetPath }];
let accPath = actualAssetPath;
for (const part of parts) {
accPath = `${accPath}/${part}`;
crumbs.push({ name: part, path: accPath });
}
return crumbs;
};
const breadcrumbs = getBreadcrumbs();
return (
<div className="asset-picker-overlay" onClick={onClose}>
<div className="asset-picker-dialog" onClick={(e) => e.stopPropagation()}>
<div className="asset-picker-header">
<h3>{t.title}</h3>
<button className="asset-picker-close" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="asset-picker-toolbar">
<button
className="toolbar-button"
onClick={handleGoBack}
disabled={!canGoBack}
title={t.back}
>
<ArrowLeft size={16} />
</button>
<div className="asset-picker-breadcrumb">
{breadcrumbs.map((crumb, index) => (
<span key={crumb.path}>
<span
className="breadcrumb-item"
onClick={() => setCurrentPath(crumb.path)}
>
{crumb.name}
</span>
{index < breadcrumbs.length - 1 && <span className="breadcrumb-separator"> / </span>}
</span>
))}
</div>
<div className="view-mode-buttons">
<button
className={`toolbar-button ${viewMode === 'list' ? 'active' : ''}`}
onClick={() => setViewMode('list')}
title={t.listView}
>
<List size={16} />
</button>
<button
className={`toolbar-button ${viewMode === 'grid' ? 'active' : ''}`}
onClick={() => setViewMode('grid')}
title={t.gridView}
>
<Grid size={16} />
</button>
</div>
</div>
<div className="asset-picker-search">
<Search size={16} className="search-icon" />
<input
type="text"
placeholder={t.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-input"
/>
{searchQuery && (
<button
className="search-clear"
onClick={() => setSearchQuery('')}
>
<X size={14} />
</button>
)}
</div>
<div className="asset-picker-content">
{loading ? (
<div className="asset-picker-loading">{t.loading}</div>
) : filteredAssets.length === 0 ? (
<div className="asset-picker-empty">{t.empty}</div>
) : (
<div className={`asset-picker-list ${viewMode}`}>
{filteredAssets.map((item, index) => (
<div
key={index}
className={`asset-picker-item ${selectedPath === item.path ? 'selected' : ''}`}
onClick={() => handleItemClick(item)}
onDoubleClick={() => handleItemDoubleClick(item)}
>
<div className="asset-icon">
{item.isDir ? (
<Folder size={viewMode === 'grid' ? 32 : 18} style={{ color: '#ffa726' }} />
) : (
<FileCode size={viewMode === 'grid' ? 32 : 18} style={{ color: '#66bb6a' }} />
)}
</div>
<div className="asset-info">
<span className="asset-name">{item.name}</span>
{viewMode === 'list' && !item.isDir && (
<div className="asset-meta">
{item.size && <span className="asset-size">{formatFileSize(item.size)}</span>}
{item.modified && <span className="asset-date">{formatDate(item.modified)}</span>}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
<div className="asset-picker-footer">
<div className="footer-info">
{filteredAssets.length} {locale === 'zh' ? '项' : 'items'}
</div>
<div className="footer-buttons">
<button className="asset-picker-cancel" onClick={onClose}>
{t.cancel}
</button>
<button
className="asset-picker-select"
onClick={handleSelect}
disabled={!selectedPath}
>
{t.select}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,831 @@
import { useState } from 'react';
import { Clipboard, Edit2, Trash2, ChevronDown, ChevronRight, Globe, Save, Folder, FileCode } from 'lucide-react';
import { save } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { Core } from '@esengine/ecs-framework';
import type { BlackboardValueType } from '@esengine/behavior-tree';
import { GlobalBlackboardService } from '@esengine/behavior-tree';
import { GlobalBlackboardTypeGenerator } from '../generators/GlobalBlackboardTypeGenerator';
import { createLogger } from '@esengine/ecs-framework';
const logger = createLogger('BehaviorTreeBlackboard');
type SimpleBlackboardType = 'number' | 'string' | 'boolean' | 'object';
interface BlackboardVariable {
key: string;
value: any;
type: SimpleBlackboardType;
}
interface BehaviorTreeBlackboardProps {
variables: Record<string, any>;
initialVariables?: Record<string, any>;
globalVariables?: Record<string, any>;
onVariableChange: (key: string, value: any) => void;
onVariableAdd: (key: string, value: any, type: SimpleBlackboardType) => void;
onVariableDelete: (key: string) => void;
onVariableRename?: (oldKey: string, newKey: string) => void;
onGlobalVariableChange?: (key: string, value: any) => void;
onGlobalVariableAdd?: (key: string, value: any, type: BlackboardValueType) => void;
onGlobalVariableDelete?: (key: string) => void;
projectPath?: string;
hasUnsavedGlobalChanges?: boolean;
onSaveGlobal?: () => void;
}
/**
* 行为树黑板变量面板
*
* 用于管理和调试行为树运行时的黑板变量
*/
export const BehaviorTreeBlackboard: React.FC<BehaviorTreeBlackboardProps> = ({
variables,
initialVariables,
globalVariables,
onVariableChange,
onVariableAdd,
onVariableDelete,
onVariableRename,
onGlobalVariableChange,
onGlobalVariableAdd,
onGlobalVariableDelete,
projectPath,
hasUnsavedGlobalChanges,
onSaveGlobal
}) => {
const [viewMode, setViewMode] = useState<'local' | 'global'>('local');
const isModified = (key: string): boolean => {
if (!initialVariables) return false;
return JSON.stringify(variables[key]) !== JSON.stringify(initialVariables[key]);
};
const handleExportTypeScript = async () => {
try {
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
const config = globalBlackboard.exportConfig();
const tsCode = GlobalBlackboardTypeGenerator.generate(config);
const outputPath = await save({
filters: [{
name: 'TypeScript',
extensions: ['ts']
}],
defaultPath: 'GlobalBlackboard.ts'
});
if (outputPath) {
await invoke('write_file_content', {
path: outputPath,
content: tsCode
});
logger.info('TypeScript 类型定义已导出', outputPath);
}
} catch (error) {
logger.error('导出 TypeScript 失败', error);
}
};
const [isAdding, setIsAdding] = useState(false);
const [newKey, setNewKey] = useState('');
const [newValue, setNewValue] = useState('');
const [newType, setNewType] = useState<BlackboardVariable['type']>('string');
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editingNewKey, setEditingNewKey] = useState('');
const [editValue, setEditValue] = useState('');
const [editType, setEditType] = useState<BlackboardVariable['type']>('string');
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const handleAddVariable = () => {
if (!newKey.trim()) return;
let parsedValue: any = newValue;
if (newType === 'number') {
parsedValue = parseFloat(newValue) || 0;
} else if (newType === 'boolean') {
parsedValue = newValue === 'true';
} else if (newType === 'object') {
try {
parsedValue = JSON.parse(newValue);
} catch {
parsedValue = {};
}
}
if (viewMode === 'global' && onGlobalVariableAdd) {
const globalType = newType as BlackboardValueType;
onGlobalVariableAdd(newKey, parsedValue, globalType);
} else {
onVariableAdd(newKey, parsedValue, newType);
}
setNewKey('');
setNewValue('');
setIsAdding(false);
};
const handleStartEdit = (key: string, value: any) => {
setEditingKey(key);
setEditingNewKey(key);
const currentType = getVariableType(value);
setEditType(currentType);
setEditValue(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value));
};
const handleSaveEdit = (key: string) => {
const newKey = editingNewKey.trim();
if (!newKey) return;
let parsedValue: any = editValue;
if (editType === 'number') {
parsedValue = parseFloat(editValue) || 0;
} else if (editType === 'boolean') {
parsedValue = editValue === 'true' || editValue === '1';
} else if (editType === 'object') {
try {
parsedValue = JSON.parse(editValue);
} catch {
return;
}
}
if (viewMode === 'global' && onGlobalVariableChange) {
if (newKey !== key && onGlobalVariableDelete) {
onGlobalVariableDelete(key);
}
onGlobalVariableChange(newKey, parsedValue);
} else {
if (newKey !== key && onVariableRename) {
onVariableRename(key, newKey);
}
onVariableChange(newKey, parsedValue);
}
setEditingKey(null);
};
const toggleGroup = (groupName: string) => {
setCollapsedGroups(prev => {
const newSet = new Set(prev);
if (newSet.has(groupName)) {
newSet.delete(groupName);
} else {
newSet.add(groupName);
}
return newSet;
});
};
const getVariableType = (value: any): BlackboardVariable['type'] => {
if (typeof value === 'number') return 'number';
if (typeof value === 'boolean') return 'boolean';
if (typeof value === 'object') return 'object';
return 'string';
};
const currentVariables = viewMode === 'global' ? (globalVariables || {}) : variables;
const variableEntries = Object.entries(currentVariables);
const currentOnDelete = viewMode === 'global' ? onGlobalVariableDelete : onVariableDelete;
const groupedVariables: Record<string, Array<{ fullKey: string; varName: string; value: any }>> = variableEntries.reduce((groups, [key, value]) => {
const parts = key.split('.');
const groupName = (parts.length > 1 && parts[0]) ? parts[0] : 'default';
const varName = parts.length > 1 ? parts.slice(1).join('.') : key;
if (!groups[groupName]) {
groups[groupName] = [];
}
const group = groups[groupName];
if (group) {
group.push({ fullKey: key, varName, value });
}
return groups;
}, {} as Record<string, Array<{ fullKey: string; varName: string; value: any }>>);
const groupNames = Object.keys(groupedVariables).sort((a, b) => {
if (a === 'default') return 1;
if (b === 'default') return -1;
return a.localeCompare(b);
});
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#1e1e1e',
color: '#cccccc'
}}>
<style>{`
.blackboard-list::-webkit-scrollbar {
width: 8px;
}
.blackboard-list::-webkit-scrollbar-track {
background: #1e1e1e;
}
.blackboard-list::-webkit-scrollbar-thumb {
background: #3c3c3c;
border-radius: 4px;
}
.blackboard-list::-webkit-scrollbar-thumb:hover {
background: #4c4c4c;
}
`}</style>
{/* 标题栏 */}
<div style={{
backgroundColor: '#2d2d2d',
borderBottom: '1px solid #333'
}}>
<div style={{
padding: '10px 12px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{
fontSize: '13px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '6px',
color: '#ccc'
}}>
<Clipboard size={14} />
<span>Blackboard</span>
</div>
<div style={{
display: 'flex',
backgroundColor: '#1e1e1e',
borderRadius: '3px',
overflow: 'hidden'
}}>
<button
onClick={() => setViewMode('local')}
style={{
padding: '3px 8px',
backgroundColor: viewMode === 'local' ? '#0e639c' : 'transparent',
border: 'none',
color: viewMode === 'local' ? '#fff' : '#888',
cursor: 'pointer',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
gap: '3px'
}}
>
<Clipboard size={11} />
Local
</button>
<button
onClick={() => setViewMode('global')}
style={{
padding: '3px 8px',
backgroundColor: viewMode === 'global' ? '#0e639c' : 'transparent',
border: 'none',
color: viewMode === 'global' ? '#fff' : '#888',
cursor: 'pointer',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
gap: '3px'
}}
>
<Globe size={11} />
Global
</button>
</div>
</div>
{/* 工具栏 */}
<div style={{
padding: '8px 12px',
backgroundColor: '#252525',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '8px'
}}>
<div style={{
flex: 1,
fontSize: '10px',
color: '#888',
display: 'flex',
alignItems: 'center',
gap: '4px',
minWidth: 0,
overflow: 'hidden'
}}>
{viewMode === 'global' && projectPath ? (
<>
<Folder size={10} style={{ flexShrink: 0 }} />
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>.ecs/global-blackboard.json</span>
</>
) : (
<span>
{viewMode === 'local' ? '当前行为树的本地变量' : '所有行为树共享的全局变量'}
</span>
)}
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
flexShrink: 0
}}>
{viewMode === 'global' && onSaveGlobal && (
<>
<button
onClick={hasUnsavedGlobalChanges ? onSaveGlobal : undefined}
disabled={!hasUnsavedGlobalChanges}
style={{
padding: '4px 6px',
backgroundColor: hasUnsavedGlobalChanges ? '#ff9800' : '#4caf50',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: hasUnsavedGlobalChanges ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
opacity: hasUnsavedGlobalChanges ? 1 : 0.7
}}
title={hasUnsavedGlobalChanges ? '点击保存全局配置' : '全局配置已保存'}
>
<Save size={12} />
</button>
<button
onClick={handleExportTypeScript}
style={{
padding: '4px 6px',
backgroundColor: '#9c27b0',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center'
}}
title="导出为 TypeScript 类型定义"
>
<FileCode size={12} />
</button>
</>
)}
<button
onClick={() => setIsAdding(true)}
style={{
padding: '4px 6px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
fontSize: '11px',
display: 'flex',
alignItems: 'center'
}}
title="添加变量"
>
+
</button>
</div>
</div>
</div>
{/* 变量列表 */}
<div className="blackboard-list" style={{
flex: 1,
overflowY: 'auto',
padding: '10px'
}}>
{variableEntries.length === 0 && !isAdding && (
<div style={{
textAlign: 'center',
color: '#666',
fontSize: '12px',
padding: '20px'
}}>
No variables yet. Click "Add" to create one.
</div>
)}
{groupNames.map(groupName => {
const isCollapsed = collapsedGroups.has(groupName);
const groupVars = groupedVariables[groupName];
if (!groupVars) return null;
return (
<div key={groupName} style={{ marginBottom: '8px' }}>
{groupName !== 'default' && (
<div
onClick={() => toggleGroup(groupName)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 6px',
backgroundColor: '#252525',
borderRadius: '3px',
cursor: 'pointer',
marginBottom: '4px',
userSelect: 'none'
}}
>
{isCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
<span style={{
fontSize: '11px',
fontWeight: 'bold',
color: '#888'
}}>
{groupName} ({groupVars.length})
</span>
</div>
)}
{!isCollapsed && groupVars.map(({ fullKey: key, varName, value }) => {
const type = getVariableType(value);
const isEditing = editingKey === key;
const handleDragStart = (e: React.DragEvent) => {
const variableData = {
variableName: key,
variableValue: value,
variableType: type
};
e.dataTransfer.setData('application/blackboard-variable', JSON.stringify(variableData));
e.dataTransfer.effectAllowed = 'copy';
};
const typeColor =
type === 'number' ? '#4ec9b0' :
type === 'boolean' ? '#569cd6' :
type === 'object' ? '#ce9178' : '#d4d4d4';
const displayValue = type === 'object' ?
JSON.stringify(value) :
String(value);
const truncatedValue = displayValue.length > 30 ?
displayValue.substring(0, 30) + '...' :
displayValue;
return (
<div
key={key}
draggable={!isEditing}
onDragStart={handleDragStart}
style={{
marginBottom: '6px',
padding: '6px 8px',
backgroundColor: '#2d2d2d',
borderRadius: '3px',
borderLeft: `3px solid ${typeColor}`,
cursor: isEditing ? 'default' : 'grab'
}}
>
{isEditing ? (
<div>
<div style={{
fontSize: '10px',
color: '#666',
marginBottom: '4px'
}}>
Name
</div>
<input
type="text"
value={editingNewKey}
onChange={(e) => setEditingNewKey(e.target.value)}
style={{
width: '100%',
padding: '4px',
marginBottom: '4px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '2px',
color: '#9cdcfe',
fontSize: '11px',
fontFamily: 'monospace'
}}
placeholder="Variable name (e.g., player.health)"
/>
<div style={{
fontSize: '10px',
color: '#666',
marginBottom: '4px'
}}>
Type
</div>
<select
value={editType}
onChange={(e) => setEditType(e.target.value as BlackboardVariable['type'])}
style={{
width: '100%',
padding: '4px',
marginBottom: '4px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '2px',
color: '#cccccc',
fontSize: '10px'
}}
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="object">Object (JSON)</option>
</select>
<div style={{
fontSize: '10px',
color: '#666',
marginBottom: '4px'
}}>
Value
</div>
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
style={{
width: '100%',
minHeight: editType === 'object' ? '60px' : '24px',
padding: '4px',
backgroundColor: '#1e1e1e',
border: '1px solid #0e639c',
borderRadius: '2px',
color: '#cccccc',
fontSize: '11px',
fontFamily: 'monospace',
resize: 'vertical',
marginBottom: '4px'
}}
/>
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={() => handleSaveEdit(key)}
style={{
padding: '3px 8px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '2px',
color: '#fff',
cursor: 'pointer',
fontSize: '10px'
}}
>
Save
</button>
<button
onClick={() => setEditingKey(null)}
style={{
padding: '3px 8px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '2px',
color: '#ccc',
cursor: 'pointer',
fontSize: '10px'
}}
>
Cancel
</button>
</div>
</div>
) : (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '8px'
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '11px',
color: '#9cdcfe',
fontWeight: 'bold',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
{varName} <span style={{
color: '#666',
fontWeight: 'normal',
fontSize: '10px'
}}>({type})</span>
{viewMode === 'local' && isModified(key) && (
<span style={{
fontSize: '9px',
color: '#ffbb00',
backgroundColor: 'rgba(255, 187, 0, 0.15)',
padding: '1px 4px',
borderRadius: '2px'
}} title="运行时修改的值,停止后会恢复">
</span>
)}
</div>
<div style={{
fontSize: '10px',
fontFamily: 'monospace',
color: typeColor,
marginTop: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
backgroundColor: (viewMode === 'local' && isModified(key)) ? 'rgba(255, 187, 0, 0.1)' : 'transparent',
padding: '1px 3px',
borderRadius: '2px'
}} title={(viewMode === 'local' && isModified(key)) ? `初始值: ${JSON.stringify(initialVariables?.[key])}\n当前值: ${displayValue}` : displayValue}>
{truncatedValue}
</div>
</div>
<div style={{
display: 'flex',
gap: '2px',
flexShrink: 0
}}>
<button
onClick={() => handleStartEdit(key, value)}
style={{
padding: '2px',
backgroundColor: 'transparent',
border: 'none',
color: '#ccc',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title="Edit"
>
<Edit2 size={12} />
</button>
<button
onClick={() => currentOnDelete && currentOnDelete(key)}
style={{
padding: '2px',
backgroundColor: 'transparent',
border: 'none',
color: '#f44336',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
</div>
)}
</div>
);
})}
</div>
);
})}
{/* 添加新变量表单 */}
{isAdding && (
<div style={{
padding: '12px',
backgroundColor: '#2d2d2d',
borderRadius: '4px',
borderLeft: '3px solid #0e639c'
}}>
<div style={{
fontSize: '13px',
fontWeight: 'bold',
marginBottom: '10px',
color: '#9cdcfe'
}}>
New Variable
</div>
<input
type="text"
placeholder="Variable name"
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
style={{
width: '100%',
padding: '6px',
marginBottom: '8px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '3px',
color: '#cccccc',
fontSize: '12px'
}}
/>
<select
value={newType}
onChange={(e) => setNewType(e.target.value as BlackboardVariable['type'])}
style={{
width: '100%',
padding: '6px',
marginBottom: '8px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '3px',
color: '#cccccc',
fontSize: '12px'
}}
>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="object">Object (JSON)</option>
</select>
<textarea
placeholder={
newType === 'object' ? '{"key": "value"}' :
newType === 'boolean' ? 'true or false' :
newType === 'number' ? '0' : 'value'
}
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
style={{
width: '100%',
minHeight: newType === 'object' ? '80px' : '30px',
padding: '6px',
marginBottom: '8px',
backgroundColor: '#1e1e1e',
border: '1px solid #3c3c3c',
borderRadius: '3px',
color: '#cccccc',
fontSize: '12px',
fontFamily: 'monospace',
resize: 'vertical'
}}
/>
<div style={{ display: 'flex', gap: '5px' }}>
<button
onClick={handleAddVariable}
style={{
padding: '6px 12px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px'
}}
>
Create
</button>
<button
onClick={() => {
setIsAdding(false);
setNewKey('');
setNewValue('');
}}
style={{
padding: '6px 12px',
backgroundColor: '#3c3c3c',
border: 'none',
borderRadius: '3px',
color: '#ccc',
cursor: 'pointer',
fontSize: '12px'
}}
>
Cancel
</button>
</div>
</div>
)}
</div>
{/* 底部信息 */}
<div style={{
padding: '8px 15px',
borderTop: '1px solid #333',
fontSize: '11px',
color: '#666',
backgroundColor: '#2d2d2d',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span>
{viewMode === 'local' ? 'Local' : 'Global'}: {variableEntries.length} variable{variableEntries.length !== 1 ? 's' : ''}
</span>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,336 @@
import React, { useEffect, useRef, useState } from 'react';
import { Play, Pause, Square, RotateCcw, Trash2, Copy } from 'lucide-react';
interface ExecutionLog {
timestamp: number;
message: string;
level: 'info' | 'success' | 'error' | 'warning';
nodeId?: string;
}
interface BehaviorTreeExecutionPanelProps {
logs: ExecutionLog[];
onClearLogs: () => void;
isRunning: boolean;
tickCount: number;
executionSpeed: number;
onSpeedChange: (speed: number) => void;
}
export const BehaviorTreeExecutionPanel: React.FC<BehaviorTreeExecutionPanelProps> = ({
logs,
onClearLogs,
isRunning,
tickCount,
executionSpeed,
onSpeedChange
}) => {
const logContainerRef = useRef<HTMLDivElement>(null);
const [copySuccess, setCopySuccess] = useState(false);
// 自动滚动到底部
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs]);
const getLevelColor = (level: string) => {
switch (level) {
case 'success': return '#4caf50';
case 'error': return '#f44336';
case 'warning': return '#ff9800';
default: return '#2196f3';
}
};
const getLevelIcon = (level: string) => {
switch (level) {
case 'success': return '✓';
case 'error': return '✗';
case 'warning': return '⚠';
default: return '';
}
};
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}.${date.getMilliseconds().toString().padStart(3, '0')}`;
};
const handleCopyLogs = () => {
const logsText = logs.map(log =>
`${formatTime(log.timestamp)} ${getLevelIcon(log.level)} ${log.message}`
).join('\n');
navigator.clipboard.writeText(logsText).then(() => {
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
}).catch(err => {
console.error('复制失败:', err);
});
};
return (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#1e1e1e',
color: '#d4d4d4',
fontFamily: 'Consolas, monospace',
fontSize: '12px'
}}>
{/* 标题栏 */}
<div style={{
padding: '8px 12px',
borderBottom: '1px solid #333',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#252526'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontWeight: 'bold' }}></span>
{isRunning && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '2px 8px',
backgroundColor: '#4caf50',
borderRadius: '3px',
fontSize: '11px'
}}>
<div style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: '#fff',
animation: 'pulse 1s infinite'
}} />
</div>
)}
<span style={{ color: '#888', fontSize: '11px' }}>
Tick: {tickCount}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{/* 速度控制 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ color: '#888', fontSize: '11px', minWidth: '60px' }}>
: {executionSpeed.toFixed(2)}x
</span>
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={() => onSpeedChange(0.05)}
style={{
padding: '2px 6px',
fontSize: '10px',
backgroundColor: executionSpeed === 0.05 ? '#0e639c' : 'transparent',
border: '1px solid #555',
borderRadius: '2px',
color: '#d4d4d4',
cursor: 'pointer'
}}
title="超慢速 (每秒3次)"
>
0.05x
</button>
<button
onClick={() => onSpeedChange(0.2)}
style={{
padding: '2px 6px',
fontSize: '10px',
backgroundColor: executionSpeed === 0.2 ? '#0e639c' : 'transparent',
border: '1px solid #555',
borderRadius: '2px',
color: '#d4d4d4',
cursor: 'pointer'
}}
title="慢速 (每秒12次)"
>
0.2x
</button>
<button
onClick={() => onSpeedChange(1.0)}
style={{
padding: '2px 6px',
fontSize: '10px',
backgroundColor: executionSpeed === 1.0 ? '#0e639c' : 'transparent',
border: '1px solid #555',
borderRadius: '2px',
color: '#d4d4d4',
cursor: 'pointer'
}}
title="正常速度 (每秒60次)"
>
1.0x
</button>
</div>
<input
type="range"
min="0.01"
max="2"
step="0.01"
value={executionSpeed}
onChange={(e) => onSpeedChange(parseFloat(e.target.value))}
style={{
width: '80px',
accentColor: '#0e639c'
}}
title="调整执行速度"
/>
</div>
<button
onClick={handleCopyLogs}
style={{
padding: '6px',
backgroundColor: copySuccess ? '#4caf50' : 'transparent',
border: '1px solid #555',
borderRadius: '3px',
color: logs.length === 0 ? '#666' : '#d4d4d4',
cursor: logs.length === 0 ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
opacity: logs.length === 0 ? 0.5 : 1,
transition: 'background-color 0.2s'
}}
title={copySuccess ? '已复制!' : '复制日志'}
disabled={logs.length === 0}
>
<Copy size={12} />
</button>
<button
onClick={onClearLogs}
style={{
padding: '6px',
backgroundColor: 'transparent',
border: '1px solid #555',
borderRadius: '3px',
color: '#d4d4d4',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px'
}}
title="清空日志"
>
<Trash2 size={12} />
</button>
</div>
</div>
{/* 日志内容 */}
<div
ref={logContainerRef}
className="execution-panel-logs"
style={{
flex: 1,
overflowY: 'auto',
padding: '8px',
backgroundColor: '#1e1e1e'
}}
>
{logs.length === 0 ? (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#666',
fontSize: '13px'
}}>
Play
</div>
) : (
logs.map((log, index) => (
<div
key={index}
style={{
display: 'flex',
gap: '8px',
padding: '4px 0',
borderBottom: index < logs.length - 1 ? '1px solid #2a2a2a' : 'none'
}}
>
<span style={{
color: '#666',
fontSize: '11px',
minWidth: '80px'
}}>
{formatTime(log.timestamp)}
</span>
<span style={{
color: getLevelColor(log.level),
fontWeight: 'bold',
minWidth: '16px'
}}>
{getLevelIcon(log.level)}
</span>
<span style={{
flex: 1,
color: log.level === 'error' ? '#f44336' : '#d4d4d4'
}}>
{log.message}
</span>
</div>
))
)}
</div>
{/* 底部状态栏 */}
<div style={{
padding: '6px 12px',
borderTop: '1px solid #333',
backgroundColor: '#252526',
fontSize: '11px',
color: '#888',
display: 'flex',
justifyContent: 'space-between'
}}>
<span>{logs.length} </span>
<span>{isRunning ? '正在运行' : '已停止'}</span>
</div>
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* 自定义滚动条样式 */
.execution-panel-logs::-webkit-scrollbar {
width: 8px;
}
.execution-panel-logs::-webkit-scrollbar-track {
background: #1e1e1e;
}
.execution-panel-logs::-webkit-scrollbar-thumb {
background: #424242;
border-radius: 4px;
}
.execution-panel-logs::-webkit-scrollbar-thumb:hover {
background: #4e4e4e;
}
/* Firefox 滚动条样式 */
.execution-panel-logs {
scrollbar-width: thin;
scrollbar-color: #424242 #1e1e1e;
}
`}</style>
</div>
);
};

View File

@@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import '../styles/BehaviorTreeNameDialog.css';
interface BehaviorTreeNameDialogProps {
isOpen: boolean;
onConfirm: (name: string) => void;
onCancel: () => void;
defaultName?: string;
}
export const BehaviorTreeNameDialog: React.FC<BehaviorTreeNameDialogProps> = ({
isOpen,
onConfirm,
onCancel,
defaultName = ''
}) => {
const [name, setName] = useState(defaultName);
const [error, setError] = useState('');
useEffect(() => {
if (isOpen) {
setName(defaultName);
setError('');
}
}, [isOpen, defaultName]);
if (!isOpen) return null;
const validateName = (value: string): boolean => {
if (!value.trim()) {
setError('行为树名称不能为空');
return false;
}
const invalidChars = /[<>:"/\\|?*]/;
if (invalidChars.test(value)) {
setError('名称包含非法字符(不能包含 < > : " / \\ | ? *');
return false;
}
setError('');
return true;
};
const handleConfirm = () => {
if (validateName(name)) {
onConfirm(name.trim());
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleConfirm();
} else if (e.key === 'Escape') {
onCancel();
}
};
const handleNameChange = (value: string) => {
setName(value);
if (error) {
validateName(value);
}
};
return (
<div className="dialog-overlay">
<div className="dialog-content">
<div className="dialog-header">
<h3></h3>
</div>
<div className="dialog-body">
<label htmlFor="btree-name">:</label>
<input
id="btree-name"
type="text"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="请输入行为树名称"
autoFocus
/>
{error && <div className="dialog-error">{error}</div>}
<div className="dialog-hint">
将保存到项目目录: .ecs/behaviors/{name || '名称'}.btree
</div>
</div>
<div className="dialog-footer">
<button onClick={onCancel} className="dialog-button dialog-button-secondary">
</button>
<button onClick={handleConfirm} className="dialog-button dialog-button-primary">
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,209 @@
import React, { useState } from 'react';
import { NodeTemplates, NodeTemplate } from '@esengine/behavior-tree';
import { NodeIcon } from './NodeIcon';
interface BehaviorTreeNodePaletteProps {
onNodeSelect?: (template: NodeTemplate) => void;
}
/**
* 行为树节点面板
*
* 显示所有可用的行为树节点模板,支持拖拽创建
*/
export const BehaviorTreeNodePalette: React.FC<BehaviorTreeNodePaletteProps> = ({
onNodeSelect
}) => {
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const allTemplates = NodeTemplates.getAllTemplates();
// 按类别分组(排除根节点类别)
const categories = ['all', ...new Set(allTemplates
.filter(t => t.category !== '根节点')
.map(t => t.category))];
const filteredTemplates = (selectedCategory === 'all'
? allTemplates
: allTemplates.filter(t => t.category === selectedCategory))
.filter(t => t.category !== '根节点');
const handleNodeClick = (template: NodeTemplate) => {
onNodeSelect?.(template);
};
const handleDragStart = (e: React.DragEvent, template: NodeTemplate) => {
const templateJson = JSON.stringify(template);
e.dataTransfer.setData('application/behavior-tree-node', templateJson);
e.dataTransfer.setData('text/plain', templateJson);
e.dataTransfer.effectAllowed = 'copy';
const dragImage = e.currentTarget as HTMLElement;
if (dragImage) {
e.dataTransfer.setDragImage(dragImage, 50, 25);
}
};
const getTypeColor = (type: string): string => {
switch (type) {
case 'composite': return '#1976d2';
case 'action': return '#388e3c';
case 'condition': return '#d32f2f';
case 'decorator': return '#fb8c00';
case 'blackboard': return '#8e24aa';
default: return '#7b1fa2';
}
};
return (
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
backgroundColor: '#1e1e1e',
color: '#cccccc',
fontFamily: 'sans-serif'
}}>
<style>{`
.node-palette-list::-webkit-scrollbar {
width: 8px;
}
.node-palette-list::-webkit-scrollbar-track {
background: #1e1e1e;
}
.node-palette-list::-webkit-scrollbar-thumb {
background: #3c3c3c;
border-radius: 4px;
}
.node-palette-list::-webkit-scrollbar-thumb:hover {
background: #4c4c4c;
}
`}</style>
{/* 类别选择器 */}
<div style={{
padding: '10px',
borderBottom: '1px solid #333',
display: 'flex',
flexWrap: 'wrap',
gap: '5px'
}}>
{categories.map(category => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
style={{
padding: '5px 10px',
backgroundColor: selectedCategory === category ? '#0e639c' : '#3c3c3c',
color: '#cccccc',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '12px'
}}
>
{category}
</button>
))}
</div>
{/* 节点列表 */}
<div className="node-palette-list" style={{
flex: 1,
overflowY: 'auto',
padding: '10px'
}}>
{filteredTemplates.map((template, index) => {
const className = template.className || '';
return (
<div
key={index}
draggable={true}
onDragStart={(e) => handleDragStart(e, template)}
onClick={() => handleNodeClick(template)}
style={{
padding: '10px',
marginBottom: '8px',
backgroundColor: '#2d2d2d',
borderLeft: `4px solid ${getTypeColor(template.type || '')}`,
borderRadius: '3px',
cursor: 'grab',
transition: 'all 0.2s',
userSelect: 'none',
WebkitUserSelect: 'none'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#3d3d3d';
e.currentTarget.style.transform = 'translateX(2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#2d2d2d';
e.currentTarget.style.transform = 'translateX(0)';
}}
onMouseDown={(e) => {
e.currentTarget.style.cursor = 'grabbing';
}}
onMouseUp={(e) => {
e.currentTarget.style.cursor = 'grab';
}}
>
<div style={{
display: 'flex',
alignItems: 'flex-start',
marginBottom: '5px',
pointerEvents: 'none',
gap: '8px'
}}>
{template.icon && (
<span style={{ display: 'flex', alignItems: 'center', paddingTop: '2px' }}>
<NodeIcon iconName={template.icon} size={16} />
</span>
)}
<div style={{ flex: 1 }}>
<div style={{ fontSize: '14px', 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: '12px',
color: '#999',
lineHeight: '1.4',
pointerEvents: 'none'
}}>
{template.description}
</div>
<div style={{
marginTop: '5px',
fontSize: '11px',
color: '#666',
pointerEvents: 'none'
}}>
{template.category}
</div>
</div>
);
})}
</div>
{/* 帮助提示 */}
<div style={{
padding: '10px',
borderTop: '1px solid #333',
fontSize: '11px',
color: '#666',
textAlign: 'center'
}}>
</div>
</div>
);
};

View File

@@ -0,0 +1,407 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NodeTemplate, PropertyDefinition } from '@esengine/behavior-tree';
import {
List, GitBranch, Layers, Shuffle,
RotateCcw, Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer,
Clock, FileText, Edit, Calculator, Code,
Equal, Dices, Settings, Database, FolderOpen, TreePine,
LucideIcon
} from 'lucide-react';
import { AssetPickerDialog } from './AssetPickerDialog';
const iconMap: Record<string, LucideIcon> = {
List, GitBranch, Layers, Shuffle,
RotateCcw, Repeat, CheckCircle, XCircle, CheckCheck, HelpCircle, Snowflake, Timer,
Clock, FileText, Edit, Calculator, Code,
Equal, Dices, Settings, Database, TreePine
};
interface BehaviorTreeNodePropertiesProps {
selectedNode?: {
template: NodeTemplate;
data: Record<string, any>;
};
onPropertyChange?: (propertyName: string, value: any) => void;
projectPath?: string | null;
}
/**
* 行为树节点属性编辑器
*
* 根据节点模板动态生成属性编辑界面
*/
export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProps> = ({
selectedNode,
onPropertyChange,
projectPath
}) => {
const { t } = useTranslation();
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
const [assetPickerProperty, setAssetPickerProperty] = useState<string | null>(null);
if (!selectedNode) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#666',
fontSize: '14px'
}}>
{t('behaviorTree.noNodeSelected')}
</div>
);
}
const { template, data } = selectedNode;
const handleChange = (propName: string, value: any) => {
onPropertyChange?.(propName, value);
};
const renderProperty = (prop: PropertyDefinition) => {
const value = data[prop.name] ?? prop.defaultValue;
switch (prop.type) {
case 'string':
case 'variable':
return (
<input
type="text"
value={value || ''}
onChange={(e) => handleChange(prop.name, e.target.value)}
placeholder={prop.description}
style={{
width: '100%',
padding: '6px',
backgroundColor: '#3c3c3c',
border: '1px solid #555',
borderRadius: '3px',
color: '#cccccc',
fontSize: '13px'
}}
/>
);
case 'number':
return (
<input
type="number"
value={value ?? ''}
onChange={(e) => handleChange(prop.name, parseFloat(e.target.value))}
min={prop.min}
max={prop.max}
step={prop.step || 1}
placeholder={prop.description}
style={{
width: '100%',
padding: '6px',
backgroundColor: '#3c3c3c',
border: '1px solid #555',
borderRadius: '3px',
color: '#cccccc',
fontSize: '13px'
}}
/>
);
case 'boolean':
return (
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type="checkbox"
checked={value || false}
onChange={(e) => handleChange(prop.name, e.target.checked)}
style={{ marginRight: '8px' }}
/>
<span style={{ fontSize: '13px' }}>{prop.description || '启用'}</span>
</label>
);
case 'select':
return (
<select
value={value || ''}
onChange={(e) => handleChange(prop.name, e.target.value)}
style={{
width: '100%',
padding: '6px',
backgroundColor: '#3c3c3c',
border: '1px solid #555',
borderRadius: '3px',
color: '#cccccc',
fontSize: '13px'
}}
>
<option value="">...</option>
{prop.options?.map((opt, idx) => (
<option key={idx} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
case 'code':
return (
<textarea
value={value || ''}
onChange={(e) => handleChange(prop.name, e.target.value)}
placeholder={prop.description}
rows={5}
style={{
width: '100%',
padding: '6px',
backgroundColor: '#3c3c3c',
border: '1px solid #555',
borderRadius: '3px',
color: '#cccccc',
fontSize: '13px',
fontFamily: 'monospace',
resize: 'vertical'
}}
/>
);
case 'blackboard':
return (
<div style={{ display: 'flex', gap: '5px' }}>
<input
type="text"
value={value || ''}
onChange={(e) => handleChange(prop.name, e.target.value)}
placeholder="黑板变量名"
style={{
flex: 1,
padding: '6px',
backgroundColor: '#3c3c3c',
border: '1px solid #555',
borderRadius: '3px',
color: '#cccccc',
fontSize: '13px'
}}
/>
<button
style={{
padding: '6px 12px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px'
}}
>
</button>
</div>
);
case 'asset':
return (
<div>
<div style={{ display: 'flex', gap: '5px' }}>
<input
type="text"
value={value || ''}
onChange={(e) => handleChange(prop.name, e.target.value)}
placeholder={prop.description || '资产ID'}
style={{
flex: 1,
padding: '6px',
backgroundColor: '#3c3c3c',
border: '1px solid #555',
borderRadius: '3px',
color: '#cccccc',
fontSize: '13px'
}}
/>
<button
onClick={() => {
setAssetPickerProperty(prop.name);
setAssetPickerOpen(true);
}}
disabled={!projectPath}
title={!projectPath ? '请先打开项目' : '浏览资产'}
style={{
padding: '6px 12px',
backgroundColor: projectPath ? '#0e639c' : '#555',
border: 'none',
borderRadius: '3px',
color: '#fff',
cursor: projectPath ? 'pointer' : 'not-allowed',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
>
<FolderOpen size={14} />
</button>
</div>
{!projectPath && (
<div style={{
marginTop: '5px',
fontSize: '11px',
color: '#f48771',
lineHeight: '1.4'
}}>
使
</div>
)}
</div>
);
default:
return null;
}
};
return (
<div style={{
height: '100%',
backgroundColor: '#1e1e1e',
color: '#cccccc',
fontFamily: 'sans-serif',
display: 'flex',
flexDirection: 'column'
}}>
{/* 节点信息 */}
<div style={{
padding: '15px',
borderBottom: '1px solid #333'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '10px'
}}>
{template.icon && (() => {
const IconComponent = iconMap[template.icon];
return IconComponent ? (
<IconComponent
size={24}
color={template.color || '#cccccc'}
style={{ marginRight: '10px' }}
/>
) : (
<span style={{ marginRight: '10px', fontSize: '24px' }}>
{template.icon}
</span>
);
})()}
<div>
<h3 style={{ margin: 0, fontSize: '16px' }}>{template.displayName}</h3>
<div style={{ fontSize: '11px', color: '#666', marginTop: '2px' }}>
{template.category}
</div>
</div>
</div>
<div style={{ fontSize: '13px', color: '#999', lineHeight: '1.5' }}>
{template.description}
</div>
</div>
{/* 属性列表 */}
<div style={{
flex: 1,
overflowY: 'auto',
padding: '15px'
}}>
{template.properties.length === 0 ? (
<div style={{ color: '#666', fontSize: '13px', textAlign: 'center', paddingTop: '20px' }}>
{t('behaviorTree.noConfigurableProperties')}
</div>
) : (
template.properties.map((prop, index) => (
<div key={index} style={{ marginBottom: '20px' }}>
<label style={{
display: 'block',
marginBottom: '8px',
fontSize: '13px',
fontWeight: 'bold',
color: '#cccccc'
}}>
{prop.label}
{prop.required && (
<span style={{ color: '#f48771', marginLeft: '4px' }}>*</span>
)}
</label>
{renderProperty(prop)}
{prop.description && prop.type !== 'boolean' && (
<div style={{
marginTop: '5px',
fontSize: '11px',
color: '#666',
lineHeight: '1.4'
}}>
{prop.description}
</div>
)}
</div>
))
)}
</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
projectPath={projectPath}
fileExtension="btree"
assetBasePath=".ecs/behaviors"
locale={t('locale') === 'zh' ? 'zh' : 'en'}
onSelect={(assetId) => {
// AssetPickerDialog 返回 assetId不含扩展名相对于 .ecs/behaviors 的路径)
handleChange(assetPickerProperty, assetId);
setAssetPickerOpen(false);
setAssetPickerProperty(null);
}}
onClose={() => {
setAssetPickerOpen(false);
setAssetPickerProperty(null);
}}
/>
)}
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useMemo, memo } from 'react';
import { LogService, LogEntry } from '@esengine/editor-core';
import { LogLevel } from '@esengine/ecs-framework';
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Maximize2, ChevronRight, ChevronDown, Wifi } from 'lucide-react';
@@ -9,6 +9,137 @@ interface ConsolePanelProps {
logService: LogService;
}
interface ParsedLogData {
isJSON: boolean;
jsonStr?: string;
extracted?: { prefix: string; json: string; suffix: string } | null;
}
const LogEntryItem = memo(({
log,
isExpanded,
onToggleExpand,
onOpenJsonViewer,
parsedData
}: {
log: LogEntry;
isExpanded: boolean;
onToggleExpand: (id: number) => void;
onOpenJsonViewer: (jsonStr: string) => void;
parsedData: ParsedLogData;
}) => {
const getLevelIcon = (level: LogLevel) => {
switch (level) {
case LogLevel.Debug:
return <Bug size={14} />;
case LogLevel.Info:
return <Info size={14} />;
case LogLevel.Warn:
return <AlertTriangle size={14} />;
case LogLevel.Error:
case LogLevel.Fatal:
return <XCircle size={14} />;
default:
return <AlertCircle size={14} />;
}
};
const getLevelClass = (level: LogLevel): string => {
switch (level) {
case LogLevel.Debug:
return 'log-entry-debug';
case LogLevel.Info:
return 'log-entry-info';
case LogLevel.Warn:
return 'log-entry-warn';
case LogLevel.Error:
case LogLevel.Fatal:
return 'log-entry-error';
default:
return '';
}
};
const formatTime = (date: Date): string => {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
const ms = date.getMilliseconds().toString().padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${ms}`;
};
const formatMessage = (message: string, isExpanded: boolean, parsedData: ParsedLogData): JSX.Element => {
const MAX_PREVIEW_LENGTH = 200;
const { isJSON, jsonStr, extracted } = parsedData;
const shouldTruncate = message.length > MAX_PREVIEW_LENGTH && !isExpanded;
return (
<div className="log-message-container">
<div className="log-message-text">
{shouldTruncate ? (
<>
{extracted && extracted.prefix && <span>{extracted.prefix} </span>}
<span className="log-message-preview">
{message.substring(0, MAX_PREVIEW_LENGTH)}...
</span>
</>
) : (
<span>{message}</span>
)}
</div>
{isJSON && jsonStr && (
<button
className="log-open-json-btn"
onClick={(e) => {
e.stopPropagation();
onOpenJsonViewer(jsonStr);
}}
title="Open in JSON Viewer"
>
<Maximize2 size={12} />
</button>
)}
</div>
);
};
const shouldShowExpander = log.message.length > 200;
return (
<div
className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''} ${isExpanded ? 'log-entry-expanded' : ''}`}
>
{shouldShowExpander && (
<div
className="log-entry-expander"
onClick={() => onToggleExpand(log.id)}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</div>
)}
<div className="log-entry-icon">
{getLevelIcon(log.level)}
</div>
<div className="log-entry-time">
{formatTime(log.timestamp)}
</div>
<div className={`log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
</div>
{log.clientId && (
<div className="log-entry-client" title={`Client: ${log.clientId}`}>
{log.clientId}
</div>
)}
<div className="log-entry-message">
{formatMessage(log.message, isExpanded, parsedData)}
</div>
</div>
);
});
LogEntryItem.displayName = 'LogEntryItem';
export function ConsolePanel({ logService }: ConsolePanelProps) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [filter, setFilter] = useState('');
@@ -64,54 +195,139 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
setLevelFilter(newFilter);
};
const filteredLogs = logs.filter(log => {
if (!levelFilter.has(log.level)) return false;
if (showRemoteOnly && log.source !== 'remote') return false;
if (filter && !log.message.toLowerCase().includes(filter.toLowerCase())) {
return false;
}
return true;
});
// 使用ref保存缓存避免每次都重新计算
const parsedLogsCacheRef = useRef<Map<number, ParsedLogData>>(new Map());
const getLevelIcon = (level: LogLevel) => {
switch (level) {
case LogLevel.Debug:
return <Bug size={14} />;
case LogLevel.Info:
return <Info size={14} />;
case LogLevel.Warn:
return <AlertTriangle size={14} />;
case LogLevel.Error:
case LogLevel.Fatal:
return <XCircle size={14} />;
default:
return <AlertCircle size={14} />;
}
};
const extractJSON = useMemo(() => {
return (message: string): { prefix: string; json: string; suffix: string } | null => {
// 快速路径:如果消息太短,直接返回
if (message.length < 2) return null;
const getLevelClass = (level: LogLevel): string => {
switch (level) {
case LogLevel.Debug:
return 'log-entry-debug';
case LogLevel.Info:
return 'log-entry-info';
case LogLevel.Warn:
return 'log-entry-warn';
case LogLevel.Error:
case LogLevel.Fatal:
return 'log-entry-error';
default:
return '';
}
};
const jsonStartChars = ['{', '['];
let startIndex = -1;
const formatTime = (date: Date): string => {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
const ms = date.getMilliseconds().toString().padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${ms}`;
};
for (const char of jsonStartChars) {
const index = message.indexOf(char);
if (index !== -1 && (startIndex === -1 || index < startIndex)) {
startIndex = index;
}
}
if (startIndex === -1) return null;
// 使用栈匹配算法更高效地找到JSON边界
const startChar = message[startIndex];
const endChar = startChar === '{' ? '}' : ']';
let depth = 0;
let inString = false;
let escape = false;
for (let i = startIndex; i < message.length; i++) {
const char = message[i];
if (escape) {
escape = false;
continue;
}
if (char === '\\') {
escape = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (char === startChar) {
depth++;
} else if (char === endChar) {
depth--;
if (depth === 0) {
// 找到匹配的结束符
const possibleJson = message.substring(startIndex, i + 1);
try {
JSON.parse(possibleJson);
return {
prefix: message.substring(0, startIndex).trim(),
json: possibleJson,
suffix: message.substring(i + 1).trim()
};
} catch {
return null;
}
}
}
}
return null;
};
}, []);
const parsedLogsCache = useMemo(() => {
const cache = parsedLogsCacheRef.current;
// 只处理新增的日志
for (const log of logs) {
// 如果已经缓存过,跳过
if (cache.has(log.id)) continue;
try {
JSON.parse(log.message);
cache.set(log.id, {
isJSON: true,
jsonStr: log.message,
extracted: null
});
} catch {
const extracted = extractJSON(log.message);
if (extracted) {
try {
JSON.parse(extracted.json);
cache.set(log.id, {
isJSON: true,
jsonStr: extracted.json,
extracted
});
} catch {
cache.set(log.id, {
isJSON: false,
extracted
});
}
} else {
cache.set(log.id, {
isJSON: false,
extracted: null
});
}
}
}
// 清理不再需要的缓存(日志被删除)
const logIds = new Set(logs.map(log => log.id));
for (const cachedId of cache.keys()) {
if (!logIds.has(cachedId)) {
cache.delete(cachedId);
}
}
return cache;
}, [logs, extractJSON]);
const filteredLogs = useMemo(() => {
return logs.filter(log => {
if (!levelFilter.has(log.level)) return false;
if (showRemoteOnly && log.source !== 'remote') return false;
if (filter && !log.message.toLowerCase().includes(filter.toLowerCase())) {
return false;
}
return true;
});
}, [logs, levelFilter, showRemoteOnly, filter]);
const toggleLogExpand = (logId: number) => {
const newExpanded = new Set(expandedLogs);
@@ -123,54 +339,6 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
setExpandedLogs(newExpanded);
};
const extractJSON = (message: string): { prefix: string; json: string; suffix: string } | null => {
const jsonStartChars = ['{', '['];
let startIndex = -1;
for (const char of jsonStartChars) {
const index = message.indexOf(char);
if (index !== -1 && (startIndex === -1 || index < startIndex)) {
startIndex = index;
}
}
if (startIndex === -1) return null;
for (let endIndex = message.length; endIndex > startIndex; endIndex--) {
const possibleJson = message.substring(startIndex, endIndex);
try {
JSON.parse(possibleJson);
return {
prefix: message.substring(0, startIndex).trim(),
json: possibleJson,
suffix: message.substring(endIndex).trim()
};
} catch {
continue;
}
}
return null;
};
const tryParseJSON = (message: string): { isJSON: boolean; parsed?: any; jsonStr?: string } => {
try {
const parsed = JSON.parse(message);
return { isJSON: true, parsed, jsonStr: message };
} catch {
const extracted = extractJSON(message);
if (extracted) {
try {
const parsed = JSON.parse(extracted.json);
return { isJSON: true, parsed, jsonStr: extracted.json };
} catch {
return { isJSON: false };
}
}
return { isJSON: false };
}
};
const openJsonViewer = (jsonStr: string) => {
try {
const parsed = JSON.parse(jsonStr);
@@ -180,43 +348,6 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
}
};
const formatMessage = (message: string, isExpanded: boolean): JSX.Element => {
const MAX_PREVIEW_LENGTH = 200;
const { isJSON, jsonStr } = tryParseJSON(message);
const extracted = extractJSON(message);
const shouldTruncate = message.length > MAX_PREVIEW_LENGTH && !isExpanded;
return (
<div className="log-message-container">
<div className="log-message-text">
{shouldTruncate ? (
<>
{extracted && extracted.prefix && <span>{extracted.prefix} </span>}
<span className="log-message-preview">
{message.substring(0, MAX_PREVIEW_LENGTH)}...
</span>
</>
) : (
<span>{message}</span>
)}
</div>
{isJSON && jsonStr && (
<button
className="log-open-json-btn"
onClick={(e) => {
e.stopPropagation();
openJsonViewer(jsonStr);
}}
title="Open in JSON Viewer"
>
<Maximize2 size={12} />
</button>
)}
</div>
);
};
const levelCounts = {
[LogLevel.Debug]: logs.filter(l => l.level === LogLevel.Debug).length,
[LogLevel.Info]: logs.filter(l => l.level === LogLevel.Info).length,
@@ -301,43 +432,16 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
<p>No logs to display</p>
</div>
) : (
filteredLogs.map(log => {
const isExpanded = expandedLogs.has(log.id);
const shouldShowExpander = log.message.length > 200;
return (
<div
key={log.id}
className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''} ${isExpanded ? 'log-entry-expanded' : ''}`}
>
{shouldShowExpander && (
<div
className="log-entry-expander"
onClick={() => toggleLogExpand(log.id)}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</div>
)}
<div className="log-entry-icon">
{getLevelIcon(log.level)}
</div>
<div className="log-entry-time">
{formatTime(log.timestamp)}
</div>
<div className={`log-entry-source ${log.source === 'remote' ? 'source-remote' : ''}`}>
[{log.source === 'remote' ? '🌐 Remote' : log.source}]
</div>
{log.clientId && (
<div className="log-entry-client" title={`Client: ${log.clientId}`}>
{log.clientId}
</div>
)}
<div className="log-entry-message">
{formatMessage(log.message, isExpanded)}
</div>
</div>
);
})
filteredLogs.map(log => (
<LogEntryItem
key={log.id}
log={log}
isExpanded={expandedLogs.has(log.id)}
onToggleExpand={toggleLogExpand}
onOpenJsonViewer={openJsonViewer}
parsedData={parsedLogsCache.get(log.id) || { isJSON: false }}
/>
))
)}
</div>
{jsonViewerData && (

View File

@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react';
import '../styles/ContextMenu.css';
export interface ContextMenuItem {
label: string;
icon?: React.ReactNode;
onClick: () => void;
disabled?: boolean;
separator?: boolean;
}
interface ContextMenuProps {
items: ContextMenuItem[];
position: { x: number; y: number };
onClose: () => void;
}
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [adjustedPosition, setAdjustedPosition] = useState(position);
useEffect(() => {
if (menuRef.current) {
const menu = menuRef.current;
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = position.x;
let y = position.y;
if (x + rect.width > viewportWidth) {
x = Math.max(0, viewportWidth - rect.width - 10);
}
if (y + rect.height > viewportHeight) {
y = Math.max(0, viewportHeight - rect.height - 10);
}
if (x !== position.x || y !== position.y) {
setAdjustedPosition({ x, y });
}
}
}, [position]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [onClose]);
return (
<div
ref={menuRef}
className="context-menu"
style={{
left: `${adjustedPosition.x}px`,
top: `${adjustedPosition.y}px`
}}
>
{items.map((item, index) => {
if (item.separator) {
return <div key={index} className="context-menu-separator" />;
}
return (
<div
key={index}
className={`context-menu-item ${item.disabled ? 'disabled' : ''}`}
onClick={() => {
if (!item.disabled) {
item.onClick();
onClose();
}
}}
>
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
<span className="context-menu-label">{item.label}</span>
</div>
);
})}
</div>
);
}

View File

@@ -1,171 +0,0 @@
import { ReactNode } from 'react';
import { TabPanel, TabItem } from './TabPanel';
import { ResizablePanel } from './ResizablePanel';
import '../styles/DockContainer.css';
export type DockPosition = 'left' | 'right' | 'top' | 'bottom' | 'center';
export interface DockablePanel {
id: string;
title: string;
content: ReactNode;
position: DockPosition;
closable?: boolean;
}
interface DockContainerProps {
panels: DockablePanel[];
onPanelClose?: (panelId: string) => void;
onPanelMove?: (panelId: string, newPosition: DockPosition) => void;
}
export function DockContainer({ panels, onPanelClose }: DockContainerProps) {
const groupedPanels = panels.reduce((acc, panel) => {
if (!acc[panel.position]) {
acc[panel.position] = [];
}
acc[panel.position].push(panel);
return acc;
}, {} as Record<DockPosition, DockablePanel[]>);
const renderPanelGroup = (position: DockPosition) => {
const positionPanels = groupedPanels[position];
if (!positionPanels || positionPanels.length === 0) return null;
const tabs: TabItem[] = positionPanels.map(panel => ({
id: panel.id,
title: panel.title,
content: panel.content,
closable: panel.closable
}));
return (
<TabPanel
tabs={tabs}
onTabClose={onPanelClose}
/>
);
};
const leftPanel = groupedPanels['left'];
const rightPanel = groupedPanels['right'];
const topPanel = groupedPanels['top'];
const bottomPanel = groupedPanels['bottom'];
const hasLeft = leftPanel && leftPanel.length > 0;
const hasRight = rightPanel && rightPanel.length > 0;
const hasTop = topPanel && topPanel.length > 0;
const hasBottom = bottomPanel && bottomPanel.length > 0;
let content = (
<div className="dock-center">
{renderPanelGroup('center')}
</div>
);
if (hasTop || hasBottom) {
content = (
<ResizablePanel
direction="vertical"
defaultSize={200}
minSize={32}
maxSize={600}
storageKey="editor-panel-bottom-size"
leftOrTop={content}
rightOrBottom={
<div className="dock-bottom">
{renderPanelGroup('bottom')}
</div>
}
/>
);
}
if (hasTop) {
content = (
<ResizablePanel
direction="vertical"
defaultSize={200}
minSize={32}
maxSize={600}
storageKey="editor-panel-top-size"
leftOrTop={
<div className="dock-top">
{renderPanelGroup('top')}
</div>
}
rightOrBottom={content}
/>
);
}
if (hasLeft || hasRight) {
if (hasLeft && hasRight) {
content = (
<ResizablePanel
direction="horizontal"
defaultSize={250}
minSize={150}
maxSize={400}
storageKey="editor-panel-left-size"
leftOrTop={
<div className="dock-left">
{renderPanelGroup('left')}
</div>
}
rightOrBottom={
<ResizablePanel
direction="horizontal"
side="right"
defaultSize={280}
minSize={200}
maxSize={500}
storageKey="editor-panel-right-size"
leftOrTop={content}
rightOrBottom={
<div className="dock-right">
{renderPanelGroup('right')}
</div>
}
/>
}
/>
);
} else if (hasLeft) {
content = (
<ResizablePanel
direction="horizontal"
defaultSize={250}
minSize={150}
maxSize={400}
storageKey="editor-panel-left-size"
leftOrTop={
<div className="dock-left">
{renderPanelGroup('left')}
</div>
}
rightOrBottom={content}
/>
);
} else {
content = (
<ResizablePanel
direction="horizontal"
side="right"
defaultSize={280}
minSize={200}
maxSize={500}
storageKey="editor-panel-right-size"
leftOrTop={content}
rightOrBottom={
<div className="dock-right">
{renderPanelGroup('right')}
</div>
}
/>
);
}
}
return <div className="dock-container">{content}</div>;
}

View File

@@ -0,0 +1,456 @@
import { useState, useEffect } from 'react';
import { X, FileJson, Binary, Info, File, FolderTree, FolderOpen, Code } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import '../styles/ExportRuntimeDialog.css';
interface ExportRuntimeDialogProps {
isOpen: boolean;
onClose: () => void;
onExport: (options: ExportOptions) => void;
hasProject: boolean;
availableFiles: string[];
currentFileName?: string;
projectPath?: string;
}
export interface ExportOptions {
mode: 'single' | 'workspace';
assetOutputPath: string;
typeOutputPath: string;
selectedFiles: string[];
fileFormats: Map<string, 'json' | 'binary'>;
}
/**
* 导出运行时资产对话框
*/
export const ExportRuntimeDialog: React.FC<ExportRuntimeDialogProps> = ({
isOpen,
onClose,
onExport,
hasProject,
availableFiles,
currentFileName,
projectPath
}) => {
const [selectedMode, setSelectedMode] = useState<'single' | 'workspace'>('workspace');
const [assetOutputPath, setAssetOutputPath] = useState('');
const [typeOutputPath, setTypeOutputPath] = useState('');
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [fileFormats, setFileFormats] = useState<Map<string, 'json' | 'binary'>>(new Map());
const [selectAll, setSelectAll] = useState(true);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
const [exportMessage, setExportMessage] = useState('');
// 从 localStorage 加载上次的路径
useEffect(() => {
if (isOpen && projectPath) {
const savedAssetPath = localStorage.getItem('export-asset-path');
const savedTypePath = localStorage.getItem('export-type-path');
if (savedAssetPath) {
setAssetOutputPath(savedAssetPath);
}
if (savedTypePath) {
setTypeOutputPath(savedTypePath);
}
}
}, [isOpen, projectPath]);
useEffect(() => {
if (isOpen) {
if (selectedMode === 'workspace') {
const newSelectedFiles = new Set(availableFiles);
setSelectedFiles(newSelectedFiles);
setSelectAll(true);
const newFormats = new Map<string, 'json' | 'binary'>();
availableFiles.forEach(file => {
newFormats.set(file, 'binary');
});
setFileFormats(newFormats);
} else {
setSelectedFiles(new Set());
setSelectAll(false);
if (currentFileName) {
const newFormats = new Map<string, 'json' | 'binary'>();
newFormats.set(currentFileName, 'binary');
setFileFormats(newFormats);
}
}
}
}, [isOpen, selectedMode, availableFiles, currentFileName]);
if (!isOpen) return null;
const handleSelectAll = () => {
if (selectAll) {
setSelectedFiles(new Set());
setSelectAll(false);
} else {
setSelectedFiles(new Set(availableFiles));
setSelectAll(true);
}
};
const handleToggleFile = (file: string) => {
const newSelected = new Set(selectedFiles);
if (newSelected.has(file)) {
newSelected.delete(file);
} else {
newSelected.add(file);
}
setSelectedFiles(newSelected);
setSelectAll(newSelected.size === availableFiles.length);
};
const handleFileFormatChange = (file: string, format: 'json' | 'binary') => {
const newFormats = new Map(fileFormats);
newFormats.set(file, format);
setFileFormats(newFormats);
};
const handleBrowseAssetPath = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: '选择资产输出目录',
defaultPath: assetOutputPath || projectPath
});
if (selected) {
const path = selected as string;
setAssetOutputPath(path);
localStorage.setItem('export-asset-path', path);
}
} catch (error) {
console.error('Failed to browse asset path:', error);
}
};
const handleBrowseTypePath = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
title: '选择类型定义输出目录',
defaultPath: typeOutputPath || projectPath
});
if (selected) {
const path = selected as string;
setTypeOutputPath(path);
localStorage.setItem('export-type-path', path);
}
} catch (error) {
console.error('Failed to browse type path:', error);
}
};
const handleExport = async () => {
if (!assetOutputPath) {
setExportMessage('错误:请选择资产输出路径');
return;
}
if (!typeOutputPath) {
setExportMessage('错误:请选择类型定义输出路径');
return;
}
if (selectedMode === 'workspace' && selectedFiles.size === 0) {
setExportMessage('错误:请至少选择一个文件');
return;
}
if (selectedMode === 'single' && !currentFileName) {
setExportMessage('错误:没有可导出的当前文件');
return;
}
// 保存路径到 localStorage
localStorage.setItem('export-asset-path', assetOutputPath);
localStorage.setItem('export-type-path', typeOutputPath);
setIsExporting(true);
setExportProgress(0);
setExportMessage('正在导出...');
try {
await onExport({
mode: selectedMode,
assetOutputPath,
typeOutputPath,
selectedFiles: selectedMode === 'workspace' ? Array.from(selectedFiles) : [currentFileName!],
fileFormats
});
setExportProgress(100);
setExportMessage('导出成功!');
} catch (error) {
setExportMessage(`导出失败:${error}`);
} finally {
setIsExporting(false);
}
};
return (
<div className="export-dialog-overlay">
<div className="export-dialog" style={{ maxWidth: '700px', width: '90%' }}>
<div className="export-dialog-header">
<h3></h3>
<button onClick={onClose} className="export-dialog-close">
<X size={20} />
</button>
</div>
<div className="export-dialog-content" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
{/* Tab 页签 */}
<div className="export-mode-tabs">
<button
className={`export-mode-tab ${selectedMode === 'workspace' ? 'active' : ''}`}
onClick={() => hasProject ? setSelectedMode('workspace') : null}
disabled={!hasProject}
>
<FolderTree size={16} />
</button>
<button
className={`export-mode-tab ${selectedMode === 'single' ? 'active' : ''}`}
onClick={() => setSelectedMode('single')}
>
<File size={16} />
</button>
</div>
{/* 资产输出路径 */}
<div className="export-section">
<h4></h4>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={assetOutputPath}
onChange={(e) => setAssetOutputPath(e.target.value)}
placeholder="选择资产输出目录(.btree.bin / .btree.json..."
style={{
flex: 1,
padding: '8px 12px',
backgroundColor: '#2d2d2d',
border: '1px solid #3a3a3a',
borderRadius: '4px',
color: '#cccccc',
fontSize: '12px'
}}
/>
<button
onClick={handleBrowseAssetPath}
style={{
padding: '8px 16px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<FolderOpen size={14} />
</button>
</div>
</div>
{/* TypeScript 类型定义输出路径 */}
<div className="export-section">
<h4>TypeScript </h4>
<div style={{ marginBottom: '12px', fontSize: '11px', color: '#999', lineHeight: '1.5' }}>
{selectedMode === 'workspace' ? (
<>
<br />
.d.ts<br />
GlobalBlackboard.ts
</>
) : (
'将导出当前行为树的黑板变量类型(.d.ts'
)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={typeOutputPath}
onChange={(e) => setTypeOutputPath(e.target.value)}
placeholder="选择类型定义输出目录..."
style={{
flex: 1,
padding: '8px 12px',
backgroundColor: '#2d2d2d',
border: '1px solid #3a3a3a',
borderRadius: '4px',
color: '#cccccc',
fontSize: '12px'
}}
/>
<button
onClick={handleBrowseTypePath}
style={{
padding: '8px 16px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: 'pointer',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}
>
<FolderOpen size={14} />
</button>
</div>
</div>
{/* 文件列表 */}
{selectedMode === 'workspace' && availableFiles.length > 0 && (
<div className="export-section">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<h4 style={{ margin: 0, fontSize: '13px', color: '#ccc' }}>
({selectedFiles.size}/{availableFiles.length})
</h4>
<button
onClick={handleSelectAll}
style={{
padding: '4px 12px',
backgroundColor: '#3a3a3a',
border: 'none',
borderRadius: '3px',
color: '#ccc',
cursor: 'pointer',
fontSize: '12px'
}}
>
{selectAll ? '取消全选' : '全选'}
</button>
</div>
<div className="export-file-list">
{availableFiles.map((file) => (
<div
key={file}
className={`export-file-item ${selectedFiles.has(file) ? 'selected' : ''}`}
>
<input
type="checkbox"
className="export-file-checkbox"
checked={selectedFiles.has(file)}
onChange={() => handleToggleFile(file)}
/>
<div className="export-file-name">
<File size={14} />
{file}.btree
</div>
<select
className="export-file-format"
value={fileFormats.get(file) || 'binary'}
onChange={(e) => handleFileFormatChange(file, e.target.value as 'json' | 'binary')}
onClick={(e) => e.stopPropagation()}
>
<option value="binary"></option>
<option value="json">JSON</option>
</select>
</div>
))}
</div>
</div>
)}
{/* 单文件模式 */}
{selectedMode === 'single' && (
<div className="export-section">
<h4></h4>
{currentFileName ? (
<div className="export-file-list">
<div className="export-file-item selected">
<div className="export-file-name" style={{ paddingLeft: '8px' }}>
<File size={14} />
{currentFileName}.btree
</div>
<select
className="export-file-format"
value={fileFormats.get(currentFileName) || 'binary'}
onChange={(e) => handleFileFormatChange(currentFileName, e.target.value as 'json' | 'binary')}
>
<option value="binary"></option>
<option value="json">JSON</option>
</select>
</div>
</div>
) : (
<div style={{
padding: '40px 20px',
textAlign: 'center',
color: '#999',
fontSize: '13px',
backgroundColor: '#252525',
borderRadius: '6px',
border: '1px solid #3a3a3a'
}}>
<File size={32} style={{ margin: '0 auto 12px', opacity: 0.5 }} />
<div></div>
<div style={{ fontSize: '11px', marginTop: '8px' }}>
</div>
</div>
)}
</div>
)}
</div>
<div className="export-dialog-footer">
{exportMessage && (
<div style={{
flex: 1,
fontSize: '12px',
color: exportMessage.startsWith('错误') ? '#f48771' : exportMessage.includes('成功') ? '#89d185' : '#ccc',
paddingLeft: '8px'
}}>
{exportMessage}
</div>
)}
{isExporting && (
<div style={{
flex: 1,
height: '4px',
backgroundColor: '#3a3a3a',
borderRadius: '2px',
overflow: 'hidden',
marginRight: '12px'
}}>
<div style={{
height: '100%',
width: `${exportProgress}%`,
backgroundColor: '#0e639c',
transition: 'width 0.3s'
}}></div>
</div>
)}
<button onClick={onClose} className="export-dialog-btn export-dialog-btn-cancel">
</button>
<button
onClick={handleExport}
className="export-dialog-btn export-dialog-btn-primary"
disabled={isExporting}
style={{ opacity: isExporting ? 0.5 : 1 }}
>
{isExporting ? '导出中...' : '导出'}
</button>
</div>
</div>
</div>
);
};

View File

@@ -1,12 +1,12 @@
import { useState, useEffect } from 'react';
import { Folder, ChevronRight, ChevronDown } from 'lucide-react';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import '../styles/FileTree.css';
interface TreeNode {
name: string;
path: string;
type: 'file' | 'folder';
extension?: string;
type: 'folder';
children?: TreeNode[];
expanded?: boolean;
loaded?: boolean;
@@ -34,8 +34,20 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
setLoading(true);
try {
const entries = await TauriAPI.listDirectory(path);
const nodes = entriesToNodes(entries);
setTree(nodes);
const children = entriesToNodes(entries);
// 创建根节点
const rootName = path.split(/[/\\]/).filter(p => p).pop() || 'Project';
const rootNode: TreeNode = {
name: rootName,
path: path,
type: 'folder',
children: children,
expanded: true,
loaded: true
};
setTree([rootNode]);
} catch (error) {
console.error('Failed to load directory:', error);
setTree([]);
@@ -45,17 +57,17 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
};
const entriesToNodes = (entries: DirectoryEntry[]): TreeNode[] => {
return entries.map(entry => ({
name: entry.name,
path: entry.path,
type: entry.is_dir ? 'folder' : 'file',
extension: !entry.is_dir && entry.name.includes('.')
? entry.name.split('.').pop()
: undefined,
children: entry.is_dir ? [] : undefined,
expanded: false,
loaded: false
}));
// 只显示文件夹,过滤掉文件
return entries
.filter(entry => entry.is_dir)
.map(entry => ({
name: entry.name,
path: entry.path,
type: 'folder' as const,
children: [],
expanded: false,
loaded: false
}));
};
const loadChildren = async (node: TreeNode): Promise<TreeNode[]> => {
@@ -72,7 +84,7 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
const updateTree = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
const newNodes: TreeNode[] = [];
for (const node of nodes) {
if (node.path === nodePath && node.type === 'folder') {
if (node.path === nodePath) {
if (!node.loaded) {
const children = await loadChildren(node);
newNodes.push({
@@ -105,28 +117,7 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
const handleNodeClick = (node: TreeNode) => {
onSelectFile?.(node.path);
if (node.type === 'folder') {
toggleNode(node.path);
}
};
const getFileIcon = (extension?: string) => {
switch (extension?.toLowerCase()) {
case 'ts':
case 'tsx':
case 'js':
case 'jsx':
return '📄';
case 'json':
return '📋';
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
return '🖼️';
default:
return '📄';
}
toggleNode(node.path);
};
const renderNode = (node: TreeNode, level: number = 0) => {
@@ -140,17 +131,15 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
style={{ paddingLeft: `${indent}px` }}
onClick={() => handleNodeClick(node)}
>
{node.type === 'folder' && (
<span className="tree-arrow">
{node.expanded ? '▼' : '▶'}
</span>
)}
<span className="tree-arrow">
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className="tree-icon">
{node.type === 'folder' ? '📁' : getFileIcon(node.extension)}
<Folder size={16} />
</span>
<span className="tree-label">{node.name}</span>
</div>
{node.type === 'folder' && node.expanded && node.children && (
{node.expanded && node.children && (
<div className="tree-children">
{node.children.map(child => renderNode(child, level + 1))}
</div>
@@ -164,7 +153,7 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
}
if (!rootPath || tree.length === 0) {
return <div className="file-tree empty">No files</div>;
return <div className="file-tree empty">No folders</div>;
}
return (

View File

@@ -0,0 +1,161 @@
import { useRef, useCallback, ReactNode, useMemo } from 'react';
import { Layout, Model, TabNode, IJsonModel, Actions, IJsonTabSetNode, IJsonRowNode } from 'flexlayout-react';
import 'flexlayout-react/style/light.css';
import '../styles/FlexLayoutDock.css';
export interface FlexDockPanel {
id: string;
title: string;
content: ReactNode;
closable?: boolean;
}
interface FlexLayoutDockContainerProps {
panels: FlexDockPanel[];
onPanelClose?: (panelId: string) => void;
}
export function FlexLayoutDockContainer({ panels, onPanelClose }: FlexLayoutDockContainerProps) {
const createDefaultLayout = useCallback((): IJsonModel => {
const leftPanels = panels.filter(p => p.id.includes('hierarchy'));
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 centerPanels = panels.filter(p =>
!leftPanels.includes(p) && !rightPanels.includes(p) && !bottomPanels.includes(p)
);
// Build center column children
const centerColumnChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
if (centerPanels.length > 0) {
centerColumnChildren.push({
type: 'tabset',
weight: 70,
children: centerPanels.map(p => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false,
})),
});
}
if (bottomPanels.length > 0) {
centerColumnChildren.push({
type: 'tabset',
weight: 30,
children: bottomPanels.map(p => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false,
})),
});
}
// Build main row children
const mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
if (leftPanels.length > 0) {
mainRowChildren.push({
type: 'tabset',
weight: 20,
children: leftPanels.map(p => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false,
})),
});
}
if (centerColumnChildren.length > 0) {
if (centerColumnChildren.length === 1) {
const centerChild = centerColumnChildren[0];
if (centerChild && centerChild.type === 'tabset') {
mainRowChildren.push({
type: 'tabset',
weight: 60,
children: centerChild.children
} as IJsonTabSetNode);
} else if (centerChild) {
mainRowChildren.push({
type: 'row',
weight: 60,
children: centerChild.children
} as IJsonRowNode);
}
} else {
mainRowChildren.push({
type: 'row',
weight: 60,
children: centerColumnChildren,
});
}
}
if (rightPanels.length > 0) {
mainRowChildren.push({
type: 'tabset',
weight: 20,
children: rightPanels.map(p => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false,
})),
});
}
return {
global: {
tabEnableClose: true,
tabEnableRename: false,
tabSetEnableMaximize: false,
tabSetEnableDrop: true,
tabSetEnableDrag: true,
tabSetEnableDivide: true,
borderEnableDrop: true,
},
borders: [],
layout: {
type: 'row',
weight: 100,
children: mainRowChildren,
},
};
}, [panels]);
const model = useMemo(() => Model.fromJson(createDefaultLayout()), [createDefaultLayout]);
const factory = useCallback((node: TabNode) => {
const component = node.getComponent();
const panel = panels.find(p => p.id === component);
return panel?.content || <div>Panel not found</div>;
}, [panels]);
const onAction = useCallback((action: any) => {
if (action.type === Actions.DELETE_TAB) {
const tabId = action.data.node;
if (onPanelClose) {
onPanelClose(tabId);
}
}
return action;
}, [onPanelClose]);
return (
<div className="flexlayout-dock-container">
<Layout
model={model}
factory={factory}
onAction={onAction}
/>
</div>
);
}

View File

@@ -1,11 +1,13 @@
import { useState, useRef, useEffect } from 'react';
import { UIRegistry, MessageHub, EditorPluginManager } from '@esengine/editor-core';
import type { MenuItem as PluginMenuItem } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import '../styles/MenuBar.css';
interface MenuItem {
label?: string;
shortcut?: string;
icon?: string;
disabled?: boolean;
separator?: boolean;
submenu?: MenuItem[];
@@ -212,15 +214,9 @@ export function MenuBar({
{ label: t('selectAll'), shortcut: 'Ctrl+A', disabled: true }
],
window: [
{ label: t('sceneHierarchy'), disabled: true },
{ label: t('inspector'), disabled: true },
{ label: t('assets'), disabled: true },
{ label: t('console'), disabled: true },
{ label: t('viewport'), disabled: true },
{ separator: true },
...pluginMenuItems.map(item => ({
label: item.label || '',
shortcut: item.shortcut,
icon: item.icon,
disabled: item.disabled,
onClick: item.onClick
})),
@@ -282,6 +278,7 @@ export function MenuBar({
if (item.separator) {
return <div key={index} className="menu-separator" />;
}
const IconComponent = item.icon ? (LucideIcons as any)[item.icon] : null;
return (
<button
key={index}
@@ -289,7 +286,10 @@ export function MenuBar({
onClick={() => handleMenuItemClick(item)}
disabled={item.disabled}
>
<span>{item.label || ''}</span>
<span className="menu-item-content">
{IconComponent && <IconComponent size={16} />}
<span>{item.label || ''}</span>
</span>
{item.shortcut && <span className="menu-shortcut">{item.shortcut}</span>}
</button>
);

View File

@@ -0,0 +1,27 @@
import React from 'react';
import * as LucideIcons from 'lucide-react';
interface NodeIconProps {
iconName?: string;
size?: number;
color?: string;
}
/**
* 节点图标组件
*
* 根据图标名称渲染对应的 Lucide 图标
*/
export const NodeIcon: React.FC<NodeIconProps> = ({ iconName, size = 16, color }) => {
if (!iconName) {
return null;
}
const IconComponent = (LucideIcons as any)[iconName];
if (!IconComponent) {
return <span>{iconName}</span>;
}
return <IconComponent size={size} color={color} />;
};

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { EditorPluginManager, IEditorPluginMetadata, EditorPluginCategory } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import { Package, CheckCircle, XCircle, Search, Grid, List, ChevronDown, ChevronRight, X } from 'lucide-react';
import '../styles/PluginManagerWindow.css';
@@ -9,11 +10,11 @@ interface PluginManagerWindowProps {
}
const categoryIcons: Record<EditorPluginCategory, string> = {
[EditorPluginCategory.Tool]: '🔧',
[EditorPluginCategory.Window]: '🪟',
[EditorPluginCategory.Inspector]: '🔍',
[EditorPluginCategory.System]: '⚙️',
[EditorPluginCategory.ImportExport]: '📦'
[EditorPluginCategory.Tool]: 'Wrench',
[EditorPluginCategory.Window]: 'LayoutGrid',
[EditorPluginCategory.Inspector]: 'Search',
[EditorPluginCategory.System]: 'Settings',
[EditorPluginCategory.ImportExport]: 'Package'
};
const categoryNames: Record<EditorPluginCategory, string> = {
@@ -86,70 +87,80 @@ export function PluginManagerWindow({ pluginManager, onClose }: PluginManagerWin
const enabledCount = plugins.filter(p => p.enabled).length;
const disabledCount = plugins.filter(p => !p.enabled).length;
const renderPluginCard = (plugin: IEditorPluginMetadata) => (
<div key={plugin.name} className={`plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-card-header">
<div className="plugin-card-icon">
{plugin.icon || <Package size={24} />}
const renderPluginCard = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-card-header">
<div className="plugin-card-icon">
{IconComponent ? <IconComponent size={24} /> : <Package size={24} />}
</div>
<div className="plugin-card-info">
<div className="plugin-card-title">{plugin.displayName}</div>
<div className="plugin-card-version">v{plugin.version}</div>
</div>
<button
className={`plugin-toggle ${plugin.enabled ? 'enabled' : 'disabled'}`}
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
>
{plugin.enabled ? <CheckCircle size={18} /> : <XCircle size={18} />}
</button>
</div>
<div className="plugin-card-info">
<div className="plugin-card-title">{plugin.displayName}</div>
<div className="plugin-card-version">v{plugin.version}</div>
{plugin.description && (
<div className="plugin-card-description">{plugin.description}</div>
)}
<div className="plugin-card-footer">
<span className="plugin-card-category">
{(() => {
const CategoryIcon = (LucideIcons as any)[categoryIcons[plugin.category]];
return CategoryIcon ? <CategoryIcon size={14} style={{ marginRight: '4px' }} /> : null;
})()}
{categoryNames[plugin.category]}
</span>
{plugin.installedAt && (
<span className="plugin-card-installed">
Installed: {new Date(plugin.installedAt).toLocaleDateString()}
</span>
)}
</div>
</div>
);
};
const renderPluginList = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-list-item ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-list-icon">
{IconComponent ? <IconComponent size={20} /> : <Package size={20} />}
</div>
<div className="plugin-list-info">
<div className="plugin-list-name">
{plugin.displayName}
<span className="plugin-list-version">v{plugin.version}</span>
</div>
{plugin.description && (
<div className="plugin-list-description">{plugin.description}</div>
)}
</div>
<div className="plugin-list-status">
{plugin.enabled ? (
<span className="status-badge enabled">Enabled</span>
) : (
<span className="status-badge disabled">Disabled</span>
)}
</div>
<button
className={`plugin-toggle ${plugin.enabled ? 'enabled' : 'disabled'}`}
className="plugin-list-toggle"
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
>
{plugin.enabled ? <CheckCircle size={18} /> : <XCircle size={18} />}
{plugin.enabled ? 'Disable' : 'Enable'}
</button>
</div>
{plugin.description && (
<div className="plugin-card-description">{plugin.description}</div>
)}
<div className="plugin-card-footer">
<span className="plugin-card-category">
{categoryIcons[plugin.category]} {categoryNames[plugin.category]}
</span>
{plugin.installedAt && (
<span className="plugin-card-installed">
Installed: {new Date(plugin.installedAt).toLocaleDateString()}
</span>
)}
</div>
</div>
);
const renderPluginList = (plugin: IEditorPluginMetadata) => (
<div key={plugin.name} className={`plugin-list-item ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-list-icon">
{plugin.icon || <Package size={20} />}
</div>
<div className="plugin-list-info">
<div className="plugin-list-name">
{plugin.displayName}
<span className="plugin-list-version">v{plugin.version}</span>
</div>
{plugin.description && (
<div className="plugin-list-description">{plugin.description}</div>
)}
</div>
<div className="plugin-list-status">
{plugin.enabled ? (
<span className="status-badge enabled">Enabled</span>
) : (
<span className="status-badge disabled">Disabled</span>
)}
</div>
<button
className="plugin-list-toggle"
onClick={() => togglePlugin(plugin.name, plugin.enabled)}
title={plugin.enabled ? 'Disable plugin' : 'Enable plugin'}
>
{plugin.enabled ? 'Disable' : 'Enable'}
</button>
</div>
);
);
};
return (
<div className="plugin-manager-overlay" onClick={onClose}>
@@ -227,7 +238,12 @@ export function PluginManagerWindow({ pluginManager, onClose }: PluginManagerWin
<button className="plugin-category-toggle">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<span className="plugin-category-icon">{categoryIcons[cat]}</span>
<span className="plugin-category-icon">
{(() => {
const CategoryIcon = (LucideIcons as any)[categoryIcons[cat]];
return CategoryIcon ? <CategoryIcon size={16} /> : null;
})()}
</span>
<span className="plugin-category-name">{categoryNames[cat]}</span>
<span className="plugin-category-count">
{categoryPlugins.length}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { EditorPluginManager, IEditorPluginMetadata, EditorPluginCategory } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import { Package, CheckCircle, XCircle, Search, Grid, List, ChevronDown, ChevronRight } from 'lucide-react';
import '../styles/PluginPanel.css';
@@ -8,11 +9,11 @@ interface PluginPanelProps {
}
const categoryIcons: Record<EditorPluginCategory, string> = {
[EditorPluginCategory.Tool]: '🔧',
[EditorPluginCategory.Window]: '🪟',
[EditorPluginCategory.Inspector]: '🔍',
[EditorPluginCategory.System]: '⚙️',
[EditorPluginCategory.ImportExport]: '📦'
[EditorPluginCategory.Tool]: 'Wrench',
[EditorPluginCategory.Window]: 'LayoutGrid',
[EditorPluginCategory.Inspector]: 'Search',
[EditorPluginCategory.System]: 'Settings',
[EditorPluginCategory.ImportExport]: 'Package'
};
const categoryNames: Record<EditorPluginCategory, string> = {
@@ -85,11 +86,13 @@ export function PluginPanel({ pluginManager }: PluginPanelProps) {
const enabledCount = plugins.filter(p => p.enabled).length;
const disabledCount = plugins.filter(p => !p.enabled).length;
const renderPluginCard = (plugin: IEditorPluginMetadata) => (
const renderPluginCard = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-card-header">
<div className="plugin-card-icon">
{plugin.icon || <Package size={24} />}
{IconComponent ? <IconComponent size={24} /> : <Package size={24} />}
</div>
<div className="plugin-card-info">
<div className="plugin-card-title">{plugin.displayName}</div>
@@ -108,7 +111,11 @@ export function PluginPanel({ pluginManager }: PluginPanelProps) {
)}
<div className="plugin-card-footer">
<span className="plugin-card-category">
{categoryIcons[plugin.category]} {categoryNames[plugin.category]}
{(() => {
const CategoryIcon = (LucideIcons as any)[categoryIcons[plugin.category]];
return CategoryIcon ? <CategoryIcon size={14} style={{ marginRight: '4px' }} /> : null;
})()}
{categoryNames[plugin.category]}
</span>
{plugin.installedAt && (
<span className="plugin-card-installed">
@@ -117,12 +124,15 @@ export function PluginPanel({ pluginManager }: PluginPanelProps) {
)}
</div>
</div>
);
);
};
const renderPluginList = (plugin: IEditorPluginMetadata) => (
const renderPluginList = (plugin: IEditorPluginMetadata) => {
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : null;
return (
<div key={plugin.name} className={`plugin-list-item ${plugin.enabled ? 'enabled' : 'disabled'}`}>
<div className="plugin-list-icon">
{plugin.icon || <Package size={20} />}
{IconComponent ? <IconComponent size={20} /> : <Package size={20} />}
</div>
<div className="plugin-list-info">
<div className="plugin-list-name">
@@ -148,7 +158,8 @@ export function PluginPanel({ pluginManager }: PluginPanelProps) {
{plugin.enabled ? 'Disable' : 'Enable'}
</button>
</div>
);
);
};
return (
<div className="plugin-panel">
@@ -215,7 +226,12 @@ export function PluginPanel({ pluginManager }: PluginPanelProps) {
<button className="plugin-category-toggle">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<span className="plugin-category-icon">{categoryIcons[cat]}</span>
<span className="plugin-category-icon">
{(() => {
const CategoryIcon = (LucideIcons as any)[categoryIcons[cat]];
return CategoryIcon ? <CategoryIcon size={16} /> : null;
})()}
</span>
<span className="plugin-category-name">{categoryNames[cat]}</span>
<span className="plugin-category-count">
{categoryPlugins.length}

View File

@@ -125,7 +125,7 @@ export function SceneHierarchy({ entityStore, messageHub }: SceneHierarchyProps)
});
// 请求第一个实体的详情以获取场景名称
if (!remoteSceneName && data.entities.length > 0) {
if (!remoteSceneName && data.entities.length > 0 && data.entities[0]) {
profilerService.requestEntityDetails(data.entities[0].id);
}
} else if (!connected) {

View File

@@ -1,62 +0,0 @@
import { useState, ReactNode } from 'react';
import '../styles/TabPanel.css';
export interface TabItem {
id: string;
title: string;
content: ReactNode;
closable?: boolean;
}
interface TabPanelProps {
tabs: TabItem[];
activeTabId?: string;
onTabChange?: (tabId: string) => void;
onTabClose?: (tabId: string) => void;
}
export function TabPanel({ tabs, activeTabId, onTabChange, onTabClose }: TabPanelProps) {
const [activeTab, setActiveTab] = useState(activeTabId || tabs[0]?.id);
const handleTabClick = (tabId: string) => {
setActiveTab(tabId);
onTabChange?.(tabId);
};
const handleCloseTab = (e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
onTabClose?.(tabId);
};
const currentTab = tabs.find(tab => tab.id === activeTab);
return (
<div className="tab-panel">
<div className="tab-header">
<div className="tab-list">
{tabs.map(tab => (
<div
key={tab.id}
className={`tab-item ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => handleTabClick(tab.id)}
>
<span className="tab-title">{tab.title}</span>
{tab.closable && (
<button
className="tab-close"
onClick={(e) => handleCloseTab(e, tab.id)}
title="Close"
>
×
</button>
)}
</div>
))}
</div>
</div>
<div className="tab-content">
{currentTab?.content}
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react';
import '../styles/Toast.css';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number;
}
interface ToastContextValue {
showToast: (message: string, type?: ToastType, duration?: number) => void;
hideToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
};
interface ToastProviderProps {
children: ReactNode;
}
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = `toast-${Date.now()}-${Math.random()}`;
const toast: Toast = { id, message, type, duration };
setToasts(prev => [...prev, toast]);
if (duration > 0) {
setTimeout(() => {
hideToast(id);
}, duration);
}
}, []);
const hideToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const getIcon = (type: ToastType) => {
switch (type) {
case 'success':
return <CheckCircle size={20} />;
case 'error':
return <XCircle size={20} />;
case 'warning':
return <AlertCircle size={20} />;
case 'info':
return <Info size={20} />;
}
};
return (
<ToastContext.Provider value={{ showToast, hideToast }}>
{children}
<div className="toast-container">
{toasts.map(toast => (
<div key={toast.id} className={`toast toast-${toast.type}`}>
<div className="toast-icon">
{getIcon(toast.type)}
</div>
<div className="toast-message">{toast.message}</div>
<button
className="toast-close"
onClick={() => hideToast(toast.id)}
aria-label="关闭"
>
<X size={16} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
};