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