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,12 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Core, Scene } from '@esengine/ecs-framework';
|
||||
import * as ECSFramework from '@esengine/ecs-framework';
|
||||
import { EditorPluginManager, UIRegistry, MessageHub, SerializerRegistry, EntityStoreService, ComponentRegistry, LocaleService, ProjectService, ComponentDiscoveryService, PropertyMetadataService, LogService, SettingsRegistry, SceneManagerService } from '@esengine/editor-core';
|
||||
import { GlobalBlackboardService } from '@esengine/behavior-tree';
|
||||
import { SceneInspectorPlugin } from './plugins/SceneInspectorPlugin';
|
||||
import { ProfilerPlugin } from './plugins/ProfilerPlugin';
|
||||
import { EditorAppearancePlugin } from './plugins/EditorAppearancePlugin';
|
||||
import { BehaviorTreePlugin } from './plugins/BehaviorTreePlugin';
|
||||
import { StartupPage } from './components/StartupPage';
|
||||
import { SceneHierarchy } from './components/SceneHierarchy';
|
||||
import { EntityInspector } from './components/EntityInspector';
|
||||
@@ -16,9 +19,11 @@ import { SettingsWindow } from './components/SettingsWindow';
|
||||
import { AboutDialog } from './components/AboutDialog';
|
||||
import { ErrorDialog } from './components/ErrorDialog';
|
||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||
import { BehaviorTreeWindow } from './components/BehaviorTreeWindow';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { Viewport } from './components/Viewport';
|
||||
import { MenuBar } from './components/MenuBar';
|
||||
import { DockContainer, DockablePanel } from './components/DockContainer';
|
||||
import { FlexLayoutDockContainer, FlexDockPanel } from './components/FlexLayoutDockContainer';
|
||||
import { TauriAPI } from './api/tauri';
|
||||
import { TauriFileAPI } from './adapters/TauriFileAPI';
|
||||
import { SettingsService } from './services/SettingsService';
|
||||
@@ -35,6 +40,9 @@ localeService.registerTranslations('en', en);
|
||||
localeService.registerTranslations('zh', zh);
|
||||
Core.services.registerInstance(LocaleService, localeService);
|
||||
|
||||
// 注册全局黑板服务
|
||||
Core.services.registerSingleton(GlobalBlackboardService);
|
||||
|
||||
function App() {
|
||||
const initRef = useRef(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
@@ -51,12 +59,14 @@ function App() {
|
||||
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
|
||||
const { t, locale, changeLocale } = useLocale();
|
||||
const [status, setStatus] = useState(t('header.status.initializing'));
|
||||
const [panels, setPanels] = useState<DockablePanel[]>([]);
|
||||
const [panels, setPanels] = useState<FlexDockPanel[]>([]);
|
||||
const [showPluginManager, setShowPluginManager] = useState(false);
|
||||
const [showProfiler, setShowProfiler] = useState(false);
|
||||
const [showPortManager, setShowPortManager] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [showBehaviorTreeEditor, setShowBehaviorTreeEditor] = useState(false);
|
||||
const [behaviorTreeFilePath, setBehaviorTreeFilePath] = useState<string | null>(null);
|
||||
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
|
||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||
const [isProfilerMode, setIsProfilerMode] = useState(false);
|
||||
@@ -137,7 +147,7 @@ function App() {
|
||||
initRef.current = true;
|
||||
|
||||
try {
|
||||
(window as any).__ECS_FRAMEWORK__ = await import('@esengine/ecs-framework');
|
||||
(window as any).__ECS_FRAMEWORK__ = ECSFramework;
|
||||
|
||||
const editorScene = new Scene();
|
||||
Core.setScene(editorScene);
|
||||
@@ -180,12 +190,15 @@ function App() {
|
||||
await pluginMgr.installEditor(new SceneInspectorPlugin());
|
||||
await pluginMgr.installEditor(new ProfilerPlugin());
|
||||
await pluginMgr.installEditor(new EditorAppearancePlugin());
|
||||
await pluginMgr.installEditor(new BehaviorTreePlugin());
|
||||
|
||||
messageHub.subscribe('ui:openWindow', (data: any) => {
|
||||
if (data.windowId === 'profiler') {
|
||||
setShowProfiler(true);
|
||||
} else if (data.windowId === 'pluginManager') {
|
||||
setShowPluginManager(true);
|
||||
} else if (data.windowId === 'behavior-tree-editor') {
|
||||
setShowBehaviorTreeEditor(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -420,6 +433,11 @@ function App() {
|
||||
}
|
||||
}, [sceneManager, locale]);
|
||||
|
||||
const handleOpenBehaviorTree = useCallback((btreePath: string) => {
|
||||
setBehaviorTreeFilePath(btreePath);
|
||||
setShowBehaviorTreeEditor(true);
|
||||
}, []);
|
||||
|
||||
const handleSaveScene = async () => {
|
||||
if (!sceneManager) {
|
||||
console.error('SceneManagerService not available');
|
||||
@@ -498,28 +516,25 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) {
|
||||
let corePanels: DockablePanel[];
|
||||
let corePanels: FlexDockPanel[];
|
||||
|
||||
if (isProfilerMode) {
|
||||
corePanels = [
|
||||
{
|
||||
id: 'scene-hierarchy',
|
||||
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
|
||||
position: 'left',
|
||||
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'inspector',
|
||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||
position: 'right',
|
||||
content: <EntityInspector entityStore={entityStore} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'console',
|
||||
title: locale === 'zh' ? '控制台' : 'Console',
|
||||
position: 'bottom',
|
||||
content: <ConsolePanel logService={logService} />,
|
||||
closable: false
|
||||
}
|
||||
@@ -529,35 +544,24 @@ function App() {
|
||||
{
|
||||
id: 'scene-hierarchy',
|
||||
title: locale === 'zh' ? '场景层级' : 'Scene Hierarchy',
|
||||
position: 'left',
|
||||
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'inspector',
|
||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||
position: 'right',
|
||||
content: <EntityInspector entityStore={entityStore} messageHub={messageHub} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'viewport',
|
||||
title: locale === 'zh' ? '视口' : 'Viewport',
|
||||
position: 'center',
|
||||
content: <Viewport locale={locale} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'assets',
|
||||
title: locale === 'zh' ? '资产' : 'Assets',
|
||||
position: 'bottom',
|
||||
content: <AssetBrowser projectPath={currentProjectPath} locale={locale} onOpenScene={handleOpenSceneByPath} />,
|
||||
content: <AssetBrowser projectPath={currentProjectPath} locale={locale} onOpenScene={handleOpenSceneByPath} onOpenBehaviorTree={handleOpenBehaviorTree} />,
|
||||
closable: false
|
||||
},
|
||||
{
|
||||
id: 'console',
|
||||
title: locale === 'zh' ? '控制台' : 'Console',
|
||||
position: 'bottom',
|
||||
content: <ConsolePanel logService={logService} />,
|
||||
closable: false
|
||||
}
|
||||
@@ -568,7 +572,7 @@ function App() {
|
||||
.filter(p => p.enabled)
|
||||
.map(p => p.name);
|
||||
|
||||
const pluginPanels: DockablePanel[] = uiRegistry.getAllPanels()
|
||||
const pluginPanels: FlexDockPanel[] = uiRegistry.getAllPanels()
|
||||
.filter(panelDesc => {
|
||||
if (!panelDesc.component) {
|
||||
return false;
|
||||
@@ -587,7 +591,6 @@ function App() {
|
||||
return {
|
||||
id: panelDesc.id,
|
||||
title: (panelDesc as any).titleZh && locale === 'zh' ? (panelDesc as any).titleZh : panelDesc.title,
|
||||
position: panelDesc.position as any,
|
||||
content: <Component />,
|
||||
closable: panelDesc.closable ?? true
|
||||
};
|
||||
@@ -596,15 +599,8 @@ function App() {
|
||||
console.log('[App] Loading plugin panels:', pluginPanels);
|
||||
setPanels([...corePanels, ...pluginPanels]);
|
||||
}
|
||||
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, isProfilerMode, handleOpenSceneByPath]);
|
||||
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, isProfilerMode, handleOpenSceneByPath, handleOpenBehaviorTree]);
|
||||
|
||||
const handlePanelMove = (panelId: string, newPosition: any) => {
|
||||
setPanels(prevPanels =>
|
||||
prevPanels.map(panel =>
|
||||
panel.id === panelId ? { ...panel, position: newPosition } : panel
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
if (!initialized) {
|
||||
return (
|
||||
@@ -689,7 +685,7 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div className="editor-content">
|
||||
<DockContainer panels={panels} onPanelMove={handlePanelMove} />
|
||||
<FlexLayoutDockContainer panels={panels} />
|
||||
</div>
|
||||
|
||||
<div className="editor-footer">
|
||||
@@ -721,6 +717,18 @@ function App() {
|
||||
<AboutDialog onClose={() => setShowAbout(false)} locale={locale} />
|
||||
)}
|
||||
|
||||
{showBehaviorTreeEditor && (
|
||||
<BehaviorTreeWindow
|
||||
isOpen={showBehaviorTreeEditor}
|
||||
onClose={() => {
|
||||
setShowBehaviorTreeEditor(false);
|
||||
setBehaviorTreeFilePath(null);
|
||||
}}
|
||||
filePath={behaviorTreeFilePath}
|
||||
projectPath={currentProjectPath}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errorDialog && (
|
||||
<ErrorDialog
|
||||
title={errorDialog.title}
|
||||
@@ -732,4 +740,12 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
function AppWithToast() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppWithToast;
|
||||
|
||||
@@ -113,12 +113,47 @@ export class TauriAPI {
|
||||
static async pathExists(path: string): Promise<boolean> {
|
||||
return await invoke<boolean>('path_exists', { path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用系统默认程序打开文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
static async openFileWithSystemApp(path: string): Promise<void> {
|
||||
await invoke('open_file_with_default_app', { filePath: path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 在文件管理器中显示文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
static async showInFolder(path: string): Promise<void> {
|
||||
await invoke('show_in_folder', { filePath: path });
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开行为树文件选择对话框
|
||||
* @returns 用户选择的文件路径,取消则返回 null
|
||||
*/
|
||||
static async openBehaviorTreeDialog(): Promise<string | null> {
|
||||
return await invoke<string | null>('open_behavior_tree_dialog');
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描项目中的所有行为树文件
|
||||
* @param projectPath 项目路径
|
||||
* @returns 行为树资产ID列表(相对于 .ecs/behaviors 的路径,不含扩展名)
|
||||
*/
|
||||
static async scanBehaviorTrees(projectPath: string): Promise<string[]> {
|
||||
return await invoke<string[]>('scan_behavior_trees', { projectPath });
|
||||
}
|
||||
}
|
||||
|
||||
export interface DirectoryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size?: number;
|
||||
modified?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,501 @@
|
||||
import { GlobalBlackboardConfig, BlackboardValueType } from '@esengine/behavior-tree';
|
||||
|
||||
/**
|
||||
* 类型生成配置选项
|
||||
*/
|
||||
export interface TypeGenerationOptions {
|
||||
/** 常量名称大小写风格 */
|
||||
constantCase?: 'UPPER_SNAKE' | 'camelCase' | 'PascalCase';
|
||||
|
||||
/** 常量对象名称 */
|
||||
constantsName?: string;
|
||||
|
||||
/** 接口名称 */
|
||||
interfaceName?: string;
|
||||
|
||||
/** 类型别名名称 */
|
||||
typeAliasName?: string;
|
||||
|
||||
/** 包装类名称 */
|
||||
wrapperClassName?: string;
|
||||
|
||||
/** 默认值对象名称 */
|
||||
defaultsName?: string;
|
||||
|
||||
/** 导入路径 */
|
||||
importPath?: string;
|
||||
|
||||
/** 是否生成常量对象 */
|
||||
includeConstants?: boolean;
|
||||
|
||||
/** 是否生成接口 */
|
||||
includeInterface?: boolean;
|
||||
|
||||
/** 是否生成类型别名 */
|
||||
includeTypeAlias?: boolean;
|
||||
|
||||
/** 是否生成包装类 */
|
||||
includeWrapperClass?: boolean;
|
||||
|
||||
/** 是否生成默认值 */
|
||||
includeDefaults?: boolean;
|
||||
|
||||
/** 自定义头部注释 */
|
||||
customHeader?: string;
|
||||
|
||||
/** 使用单引号还是双引号 */
|
||||
quoteStyle?: 'single' | 'double';
|
||||
|
||||
/** 是否在文件末尾添加换行 */
|
||||
trailingNewline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局黑板 TypeScript 类型生成器
|
||||
*
|
||||
* 将全局黑板配置导出为 TypeScript 类型定义,提供:
|
||||
* - 编译时类型检查
|
||||
* - IDE 自动补全
|
||||
* - 避免拼写错误
|
||||
* - 重构友好
|
||||
*/
|
||||
export class GlobalBlackboardTypeGenerator {
|
||||
/**
|
||||
* 默认生成选项
|
||||
*/
|
||||
static readonly DEFAULT_OPTIONS: Required<TypeGenerationOptions> = {
|
||||
constantCase: 'UPPER_SNAKE',
|
||||
constantsName: 'GlobalVars',
|
||||
interfaceName: 'GlobalBlackboardTypes',
|
||||
typeAliasName: 'GlobalVariableName',
|
||||
wrapperClassName: 'TypedGlobalBlackboard',
|
||||
defaultsName: 'GlobalBlackboardDefaults',
|
||||
importPath: '@esengine/behavior-tree',
|
||||
includeConstants: true,
|
||||
includeInterface: true,
|
||||
includeTypeAlias: true,
|
||||
includeWrapperClass: true,
|
||||
includeDefaults: true,
|
||||
customHeader: '',
|
||||
quoteStyle: 'single',
|
||||
trailingNewline: true
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成 TypeScript 类型定义代码
|
||||
*
|
||||
* @param config 全局黑板配置
|
||||
* @param options 生成选项
|
||||
* @returns TypeScript 代码字符串
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 使用默认选项
|
||||
* const code = GlobalBlackboardTypeGenerator.generate(config);
|
||||
*
|
||||
* // 自定义命名
|
||||
* const code = GlobalBlackboardTypeGenerator.generate(config, {
|
||||
* constantsName: 'GameVars',
|
||||
* wrapperClassName: 'GameBlackboard'
|
||||
* });
|
||||
*
|
||||
* // 只生成接口和类型别名,不生成包装类
|
||||
* const code = GlobalBlackboardTypeGenerator.generate(config, {
|
||||
* includeWrapperClass: false,
|
||||
* includeDefaults: false
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
static generate(config: GlobalBlackboardConfig, options?: TypeGenerationOptions): string {
|
||||
const opts = { ...this.DEFAULT_OPTIONS, ...options };
|
||||
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
||||
const variables = config.variables || [];
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// 生成文件头部注释
|
||||
parts.push(this.generateHeader(now, opts));
|
||||
|
||||
// 根据配置生成各个部分
|
||||
if (opts.includeConstants) {
|
||||
parts.push(this.generateConstants(variables, opts));
|
||||
}
|
||||
|
||||
if (opts.includeInterface) {
|
||||
parts.push(this.generateInterface(variables, opts));
|
||||
}
|
||||
|
||||
if (opts.includeTypeAlias) {
|
||||
parts.push(this.generateTypeAliases(opts));
|
||||
}
|
||||
|
||||
if (opts.includeWrapperClass) {
|
||||
parts.push(this.generateTypedClass(opts));
|
||||
}
|
||||
|
||||
if (opts.includeDefaults) {
|
||||
parts.push(this.generateDefaults(variables, opts));
|
||||
}
|
||||
|
||||
// 组合所有部分
|
||||
let code = parts.join('\n\n');
|
||||
|
||||
// 添加文件末尾换行
|
||||
if (opts.trailingNewline && !code.endsWith('\n')) {
|
||||
code += '\n';
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成文件头部注释
|
||||
*/
|
||||
private static generateHeader(timestamp: string, opts: Required<TypeGenerationOptions>): string {
|
||||
const customHeader = opts.customHeader || `/**
|
||||
* 全局黑板类型定义
|
||||
*
|
||||
* ⚠️ 此文件由编辑器自动生成,请勿手动修改!
|
||||
* 生成时间: ${timestamp}
|
||||
*/`;
|
||||
|
||||
return `${customHeader}
|
||||
|
||||
import { GlobalBlackboardService } from '${opts.importPath}';`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成常量对象
|
||||
*/
|
||||
private static generateConstants(variables: any[], opts: Required<TypeGenerationOptions>): string {
|
||||
const quote = opts.quoteStyle === 'single' ? "'" : '"';
|
||||
|
||||
if (variables.length === 0) {
|
||||
return `/**
|
||||
* 全局变量名称常量
|
||||
*/
|
||||
export const ${opts.constantsName} = {} as const;`;
|
||||
}
|
||||
|
||||
// 按命名空间分组
|
||||
const grouped = this.groupVariablesByNamespace(variables);
|
||||
|
||||
if (Object.keys(grouped).length === 1 && grouped[''] !== undefined) {
|
||||
// 无命名空间,扁平结构
|
||||
const entries = variables
|
||||
.map(v => ` ${this.transformName(v.name, opts.constantCase)}: ${quote}${v.name}${quote}`)
|
||||
.join(',\n');
|
||||
|
||||
return `/**
|
||||
* 全局变量名称常量
|
||||
* 使用常量避免拼写错误
|
||||
*/
|
||||
export const ${opts.constantsName} = {
|
||||
${entries}
|
||||
} as const;`;
|
||||
} else {
|
||||
// 有命名空间,分组结构
|
||||
const namespaces = Object.entries(grouped)
|
||||
.map(([namespace, vars]) => {
|
||||
if (namespace === '') {
|
||||
// 根级别变量
|
||||
return vars
|
||||
.map(v => ` ${this.transformName(v.name, opts.constantCase)}: ${quote}${v.name}${quote}`)
|
||||
.join(',\n');
|
||||
} else {
|
||||
// 命名空间变量
|
||||
const nsName = this.toPascalCase(namespace);
|
||||
const entries = vars
|
||||
.map(v => {
|
||||
const shortName = v.name.substring(namespace.length + 1);
|
||||
return ` ${this.transformName(shortName, opts.constantCase)}: ${quote}${v.name}${quote}`;
|
||||
})
|
||||
.join(',\n');
|
||||
return ` ${nsName}: {\n${entries}\n }`;
|
||||
}
|
||||
})
|
||||
.join(',\n');
|
||||
|
||||
return `/**
|
||||
* 全局变量名称常量
|
||||
* 使用常量避免拼写错误
|
||||
*/
|
||||
export const ${opts.constantsName} = {
|
||||
${namespaces}
|
||||
} as const;`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成接口定义
|
||||
*/
|
||||
private static generateInterface(variables: any[], opts: Required<TypeGenerationOptions>): string {
|
||||
if (variables.length === 0) {
|
||||
return `/**
|
||||
* 全局变量类型定义
|
||||
*/
|
||||
export interface ${opts.interfaceName} {}`;
|
||||
}
|
||||
|
||||
const properties = variables
|
||||
.map(v => {
|
||||
const tsType = this.mapBlackboardTypeToTS(v.type);
|
||||
const comment = v.description ? ` /** ${v.description} */\n` : '';
|
||||
return `${comment} ${v.name}: ${tsType};`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `/**
|
||||
* 全局变量类型定义
|
||||
*/
|
||||
export interface ${opts.interfaceName} {
|
||||
${properties}
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成类型别名
|
||||
*/
|
||||
private static generateTypeAliases(opts: Required<TypeGenerationOptions>): string {
|
||||
return `/**
|
||||
* 全局变量名称联合类型
|
||||
*/
|
||||
export type ${opts.typeAliasName} = keyof ${opts.interfaceName};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成类型安全包装类
|
||||
*/
|
||||
private static generateTypedClass(opts: Required<TypeGenerationOptions>): string {
|
||||
return `/**
|
||||
* 类型安全的全局黑板服务包装器
|
||||
*
|
||||
* @example
|
||||
* \`\`\`typescript
|
||||
* // 游戏运行时使用
|
||||
* const service = core.services.resolve(GlobalBlackboardService);
|
||||
* const gb = new ${opts.wrapperClassName}(service);
|
||||
*
|
||||
* // 类型安全的获取
|
||||
* const hp = gb.getValue('playerHP'); // 类型: number | undefined
|
||||
*
|
||||
* // 类型安全的设置
|
||||
* gb.setValue('playerHP', 100); // ✅ 正确
|
||||
* gb.setValue('playerHP', 'invalid'); // ❌ 编译错误
|
||||
* \`\`\`
|
||||
*/
|
||||
export class ${opts.wrapperClassName} {
|
||||
constructor(private service: GlobalBlackboardService) {}
|
||||
|
||||
/**
|
||||
* 获取全局变量(类型安全)
|
||||
*/
|
||||
getValue<K extends ${opts.typeAliasName}>(
|
||||
name: K
|
||||
): ${opts.interfaceName}[K] | undefined {
|
||||
return this.service.getValue(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全局变量(类型安全)
|
||||
*/
|
||||
setValue<K extends ${opts.typeAliasName}>(
|
||||
name: K,
|
||||
value: ${opts.interfaceName}[K]
|
||||
): boolean {
|
||||
return this.service.setValue(name, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查全局变量是否存在
|
||||
*/
|
||||
hasVariable(name: ${opts.typeAliasName}): boolean {
|
||||
return this.service.hasVariable(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有变量名
|
||||
*/
|
||||
getVariableNames(): ${opts.typeAliasName}[] {
|
||||
return this.service.getVariableNames() as ${opts.typeAliasName}[];
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成默认值配置
|
||||
*/
|
||||
private static generateDefaults(variables: any[], opts: Required<TypeGenerationOptions>): string {
|
||||
if (variables.length === 0) {
|
||||
return `/**
|
||||
* 默认值配置
|
||||
*/
|
||||
export const ${opts.defaultsName}: ${opts.interfaceName} = {};`;
|
||||
}
|
||||
|
||||
const properties = variables
|
||||
.map(v => {
|
||||
const value = this.formatValue(v.value, v.type, opts);
|
||||
return ` ${v.name}: ${value}`;
|
||||
})
|
||||
.join(',\n');
|
||||
|
||||
return `/**
|
||||
* 默认值配置
|
||||
*
|
||||
* 可在游戏启动时用于初始化全局黑板
|
||||
*
|
||||
* @example
|
||||
* \`\`\`typescript
|
||||
* // 获取服务
|
||||
* const service = core.services.resolve(GlobalBlackboardService);
|
||||
*
|
||||
* // 初始化配置
|
||||
* const config = {
|
||||
* version: '1.0',
|
||||
* variables: Object.entries(${opts.defaultsName}).map(([name, value]) => ({
|
||||
* name,
|
||||
* type: typeof value as BlackboardValueType,
|
||||
* value
|
||||
* }))
|
||||
* };
|
||||
* service.importConfig(config);
|
||||
* \`\`\`
|
||||
*/
|
||||
export const ${opts.defaultsName}: ${opts.interfaceName} = {
|
||||
${properties}
|
||||
};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按命名空间分组变量
|
||||
*/
|
||||
private static groupVariablesByNamespace(variables: any[]): Record<string, any[]> {
|
||||
const groups: Record<string, any[]> = { '': [] };
|
||||
|
||||
for (const variable of variables) {
|
||||
const dotIndex = variable.name.indexOf('.');
|
||||
if (dotIndex === -1) {
|
||||
groups['']!.push(variable);
|
||||
} else {
|
||||
const namespace = variable.name.substring(0, dotIndex);
|
||||
if (!groups[namespace]) {
|
||||
groups[namespace] = [];
|
||||
}
|
||||
groups[namespace]!.push(variable);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将变量名转换为常量名(UPPER_SNAKE_CASE)
|
||||
*/
|
||||
private static toConstantName(name: string): string {
|
||||
// player.hp -> PLAYER_HP
|
||||
// playerHP -> PLAYER_HP
|
||||
return name
|
||||
.replace(/\./g, '_')
|
||||
.replace(/([a-z])([A-Z])/g, '$1_$2')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PascalCase
|
||||
*/
|
||||
private static toPascalCase(str: string): string {
|
||||
return str
|
||||
.split(/[._-]/)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 映射黑板类型到 TypeScript 类型
|
||||
*/
|
||||
private static mapBlackboardTypeToTS(type: BlackboardValueType): string {
|
||||
switch (type) {
|
||||
case BlackboardValueType.Number:
|
||||
return 'number';
|
||||
case BlackboardValueType.String:
|
||||
return 'string';
|
||||
case BlackboardValueType.Boolean:
|
||||
return 'boolean';
|
||||
case BlackboardValueType.Vector2:
|
||||
return '{ x: number; y: number }';
|
||||
case BlackboardValueType.Vector3:
|
||||
return '{ x: number; y: number; z: number }';
|
||||
case BlackboardValueType.Object:
|
||||
return 'any';
|
||||
case BlackboardValueType.Array:
|
||||
return 'any[]';
|
||||
default:
|
||||
return 'any';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化值为 TypeScript 字面量
|
||||
*/
|
||||
private static formatValue(value: any, type: BlackboardValueType, opts: Required<TypeGenerationOptions>): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'undefined';
|
||||
}
|
||||
|
||||
const quote = opts.quoteStyle === 'single' ? "'" : '"';
|
||||
const escapeRegex = opts.quoteStyle === 'single' ? /'/g : /"/g;
|
||||
const escapeChar = opts.quoteStyle === 'single' ? "\\'" : '\\"';
|
||||
|
||||
switch (type) {
|
||||
case BlackboardValueType.String:
|
||||
return `${quote}${value.toString().replace(escapeRegex, escapeChar)}${quote}`;
|
||||
case BlackboardValueType.Number:
|
||||
case BlackboardValueType.Boolean:
|
||||
return String(value);
|
||||
case BlackboardValueType.Vector2:
|
||||
if (typeof value === 'object' && value.x !== undefined && value.y !== undefined) {
|
||||
return `{ x: ${value.x}, y: ${value.y} }`;
|
||||
}
|
||||
return '{ x: 0, y: 0 }';
|
||||
case BlackboardValueType.Vector3:
|
||||
if (typeof value === 'object' && value.x !== undefined && value.y !== undefined && value.z !== undefined) {
|
||||
return `{ x: ${value.x}, y: ${value.y}, z: ${value.z} }`;
|
||||
}
|
||||
return '{ x: 0, y: 0, z: 0 }';
|
||||
case BlackboardValueType.Array:
|
||||
return '[]';
|
||||
case BlackboardValueType.Object:
|
||||
return '{}';
|
||||
default:
|
||||
return 'undefined';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据指定的大小写风格转换变量名
|
||||
*/
|
||||
private static transformName(name: string, caseStyle: 'UPPER_SNAKE' | 'camelCase' | 'PascalCase'): string {
|
||||
switch (caseStyle) {
|
||||
case 'UPPER_SNAKE':
|
||||
return this.toConstantName(name);
|
||||
case 'camelCase':
|
||||
return this.toCamelCase(name);
|
||||
case 'PascalCase':
|
||||
return this.toPascalCase(name);
|
||||
default:
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 camelCase
|
||||
*/
|
||||
private static toCamelCase(str: string): string {
|
||||
const parts = str.split(/[._-]/);
|
||||
if (parts.length === 0) return str;
|
||||
return (parts[0] || '').toLowerCase() + parts.slice(1)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { BlackboardValueType } from '@esengine/behavior-tree';
|
||||
|
||||
/**
|
||||
* 局部黑板变量信息
|
||||
*/
|
||||
export interface LocalBlackboardVariable {
|
||||
name: string;
|
||||
type: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 局部黑板类型生成配置
|
||||
*/
|
||||
export interface LocalTypeGenerationOptions {
|
||||
/** 行为树名称 */
|
||||
behaviorTreeName: string;
|
||||
|
||||
/** 是否生成常量枚举 */
|
||||
includeConstants?: boolean;
|
||||
|
||||
/** 是否生成默认值 */
|
||||
includeDefaults?: boolean;
|
||||
|
||||
/** 是否生成辅助函数 */
|
||||
includeHelpers?: boolean;
|
||||
|
||||
/** 使用单引号还是双引号 */
|
||||
quoteStyle?: 'single' | 'double';
|
||||
}
|
||||
|
||||
/**
|
||||
* 局部黑板 TypeScript 类型生成器
|
||||
*
|
||||
* 为行为树的局部黑板变量生成类型安全的 TypeScript 定义
|
||||
*/
|
||||
export class LocalBlackboardTypeGenerator {
|
||||
/**
|
||||
* 生成局部黑板的 TypeScript 类型定义
|
||||
*
|
||||
* @param variables 黑板变量列表
|
||||
* @param options 生成配置
|
||||
* @returns TypeScript 代码
|
||||
*/
|
||||
static generate(
|
||||
variables: Record<string, any>,
|
||||
options: LocalTypeGenerationOptions
|
||||
): string {
|
||||
const opts = {
|
||||
includeConstants: true,
|
||||
includeDefaults: true,
|
||||
includeHelpers: true,
|
||||
quoteStyle: 'single' as const,
|
||||
...options
|
||||
};
|
||||
|
||||
const quote = opts.quoteStyle === 'single' ? "'" : '"';
|
||||
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
||||
const treeName = opts.behaviorTreeName;
|
||||
const interfaceName = `${this.toPascalCase(treeName)}Blackboard`;
|
||||
const constantsName = `${this.toPascalCase(treeName)}Vars`;
|
||||
const defaultsName = `${this.toPascalCase(treeName)}Defaults`;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// 文件头部注释
|
||||
parts.push(`/**
|
||||
* 行为树黑板变量类型定义
|
||||
*
|
||||
* 行为树: ${treeName}
|
||||
* ⚠️ 此文件由编辑器自动生成,请勿手动修改!
|
||||
* 生成时间: ${now}
|
||||
*/`);
|
||||
|
||||
const varEntries = Object.entries(variables);
|
||||
|
||||
// 如果没有变量
|
||||
if (varEntries.length === 0) {
|
||||
parts.push(`\n/**
|
||||
* 黑板变量类型定义(空)
|
||||
*/
|
||||
export interface ${interfaceName} {}`);
|
||||
return parts.join('\n') + '\n';
|
||||
}
|
||||
|
||||
// 生成常量枚举
|
||||
if (opts.includeConstants) {
|
||||
const constants = varEntries
|
||||
.map(([name]) => ` ${this.toConstantName(name)}: ${quote}${name}${quote}`)
|
||||
.join(',\n');
|
||||
|
||||
parts.push(`\n/**
|
||||
* 黑板变量名称常量
|
||||
* 使用常量避免拼写错误
|
||||
*
|
||||
* @example
|
||||
* \`\`\`typescript
|
||||
* // 使用常量代替字符串
|
||||
* const hp = blackboard.getValue(${constantsName}.PLAYER_HP); // ✅ 类型安全
|
||||
* const hp = blackboard.getValue('playerHP'); // ❌ 容易拼写错误
|
||||
* \`\`\`
|
||||
*/
|
||||
export const ${constantsName} = {
|
||||
${constants}
|
||||
} as const;`);
|
||||
}
|
||||
|
||||
// 生成类型接口
|
||||
const interfaceProps = varEntries
|
||||
.map(([name, value]) => {
|
||||
const tsType = this.inferType(value);
|
||||
return ` ${name}: ${tsType};`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
parts.push(`\n/**
|
||||
* 黑板变量类型定义
|
||||
*/
|
||||
export interface ${interfaceName} {
|
||||
${interfaceProps}
|
||||
}`);
|
||||
|
||||
// 生成变量名联合类型
|
||||
parts.push(`\n/**
|
||||
* 黑板变量名称联合类型
|
||||
*/
|
||||
export type ${this.toPascalCase(treeName)}VariableName = keyof ${interfaceName};`);
|
||||
|
||||
// 生成默认值
|
||||
if (opts.includeDefaults) {
|
||||
const defaultProps = varEntries
|
||||
.map(([name, value]) => {
|
||||
const formattedValue = this.formatValue(value, opts.quoteStyle);
|
||||
return ` ${name}: ${formattedValue}`;
|
||||
})
|
||||
.join(',\n');
|
||||
|
||||
parts.push(`\n/**
|
||||
* 黑板变量默认值
|
||||
*
|
||||
* 可用于初始化行为树黑板
|
||||
*
|
||||
* @example
|
||||
* \`\`\`typescript
|
||||
* // 创建行为树时使用默认值
|
||||
* const blackboard = { ...${defaultsName} };
|
||||
* const tree = new BehaviorTree(rootNode, blackboard);
|
||||
* \`\`\`
|
||||
*/
|
||||
export const ${defaultsName}: ${interfaceName} = {
|
||||
${defaultProps}
|
||||
};`);
|
||||
}
|
||||
|
||||
// 生成辅助函数
|
||||
if (opts.includeHelpers) {
|
||||
parts.push(`\n/**
|
||||
* 创建类型安全的黑板访问器
|
||||
*
|
||||
* @example
|
||||
* \`\`\`typescript
|
||||
* const blackboard = create${this.toPascalCase(treeName)}Blackboard();
|
||||
*
|
||||
* // 类型安全的访问
|
||||
* const hp = blackboard.playerHP; // 类型: number
|
||||
* blackboard.playerHP = 100; // ✅ 正确
|
||||
* blackboard.playerHP = 'invalid'; // ❌ 编译错误
|
||||
* \`\`\`
|
||||
*/
|
||||
export function create${this.toPascalCase(treeName)}Blackboard(
|
||||
initialValues?: Partial<${interfaceName}>
|
||||
): ${interfaceName} {
|
||||
return { ...${defaultsName}, ...initialValues };
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫:检查变量名是否有效
|
||||
*/
|
||||
export function is${this.toPascalCase(treeName)}Variable(
|
||||
name: string
|
||||
): name is ${this.toPascalCase(treeName)}VariableName {
|
||||
return name in ${defaultsName};
|
||||
}`);
|
||||
}
|
||||
|
||||
return parts.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断 TypeScript 类型
|
||||
*/
|
||||
private static inferType(value: any): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'any';
|
||||
}
|
||||
|
||||
const type = typeof value;
|
||||
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return 'number';
|
||||
case 'string':
|
||||
return 'string';
|
||||
case 'boolean':
|
||||
return 'boolean';
|
||||
case 'object':
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return 'any[]';
|
||||
}
|
||||
const elementType = this.inferType(value[0]);
|
||||
return `${elementType}[]`;
|
||||
}
|
||||
// 检查是否是 Vector2 或 Vector3
|
||||
if ('x' in value && 'y' in value) {
|
||||
if ('z' in value) {
|
||||
return '{ x: number; y: number; z: number }';
|
||||
}
|
||||
return '{ x: number; y: number }';
|
||||
}
|
||||
return 'any';
|
||||
default:
|
||||
return 'any';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化值为 TypeScript 字面量
|
||||
*/
|
||||
private static formatValue(value: any, quoteStyle: 'single' | 'double'): string {
|
||||
if (value === null) {
|
||||
return 'null';
|
||||
}
|
||||
if (value === undefined) {
|
||||
return 'undefined';
|
||||
}
|
||||
|
||||
const quote = quoteStyle === 'single' ? "'" : '"';
|
||||
const type = typeof value;
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
const escaped = value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(quoteStyle === 'single' ? /'/g : /"/g,
|
||||
quoteStyle === 'single' ? "\\'" : '\\"');
|
||||
return `${quote}${escaped}${quote}`;
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
return String(value);
|
||||
case 'object':
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '[]';
|
||||
}
|
||||
const items = value.map(v => this.formatValue(v, quoteStyle)).join(', ');
|
||||
return `[${items}]`;
|
||||
}
|
||||
// Vector2/Vector3
|
||||
if ('x' in value && 'y' in value) {
|
||||
if ('z' in value) {
|
||||
return `{ x: ${value.x}, y: ${value.y}, z: ${value.z} }`;
|
||||
}
|
||||
return `{ x: ${value.x}, y: ${value.y} }`;
|
||||
}
|
||||
// 普通对象
|
||||
return '{}';
|
||||
default:
|
||||
return 'undefined';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 UPPER_SNAKE_CASE
|
||||
*/
|
||||
private static toConstantName(name: string): string {
|
||||
return name
|
||||
.replace(/\./g, '_')
|
||||
.replace(/([a-z])([A-Z])/g, '$1_$2')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 PascalCase
|
||||
*/
|
||||
private static toPascalCase(str: string): string {
|
||||
return str
|
||||
.split(/[._-]/)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
20
packages/editor-app/src/i18n/config.ts
Normal file
20
packages/editor-app/src/i18n/config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import zh from './locales/zh.json';
|
||||
import en from './locales/en.json';
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
zh: { translation: zh },
|
||||
en: { translation: en }
|
||||
},
|
||||
lng: 'zh',
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
48
packages/editor-app/src/i18n/locales/en.json
Normal file
48
packages/editor-app/src/i18n/locales/en.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"behaviorTree": {
|
||||
"title": "Behavior Tree Editor",
|
||||
"close": "Close",
|
||||
"nodePalette": "Node Palette",
|
||||
"properties": "Properties",
|
||||
"blackboard": "Blackboard",
|
||||
"noNodeSelected": "No node selected",
|
||||
"noConfigurableProperties": "This node has no configurable properties",
|
||||
"apply": "Apply",
|
||||
"reset": "Reset",
|
||||
"addVariable": "Add Variable",
|
||||
"variableName": "Variable Name",
|
||||
"type": "Type",
|
||||
"value": "Value",
|
||||
"defaultGroup": "Default Group",
|
||||
"rootNode": "Root Node",
|
||||
"rootNodeOnlyOneChild": "Root node can only connect to one child",
|
||||
"dragToCreate": "Drag nodes from the left to below the root node to start creating behavior tree",
|
||||
"connectFirst": "Connect the root node with the first node first",
|
||||
"nodeCount": "Nodes",
|
||||
"noSelection": "No selection",
|
||||
"selectedCount": "{{count}} nodes selected",
|
||||
"idle": "Idle",
|
||||
"running": "Running",
|
||||
"paused": "Paused",
|
||||
"step": "Step",
|
||||
"run": "Run",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"stop": "Stop",
|
||||
"stepExecution": "Step Execution",
|
||||
"resetExecution": "Reset",
|
||||
"clear": "Clear",
|
||||
"resetView": "Reset View",
|
||||
"tick": "Tick",
|
||||
"executing": "Executing",
|
||||
"success": "Success",
|
||||
"failure": "Failure",
|
||||
"startingExecution": "Starting execution from root...",
|
||||
"tickNumber": "Tick {{tick}}",
|
||||
"executionStopped": "Execution stopped after {{tick}} ticks",
|
||||
"executionPaused": "Execution paused",
|
||||
"executionResumed": "Execution resumed",
|
||||
"resetToInitial": "Reset to initial state",
|
||||
"currentValue": "Current Value"
|
||||
}
|
||||
}
|
||||
48
packages/editor-app/src/i18n/locales/zh.json
Normal file
48
packages/editor-app/src/i18n/locales/zh.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"behaviorTree": {
|
||||
"title": "行为树编辑器",
|
||||
"close": "关闭",
|
||||
"nodePalette": "节点面板",
|
||||
"properties": "属性",
|
||||
"blackboard": "黑板",
|
||||
"noNodeSelected": "未选择节点",
|
||||
"noConfigurableProperties": "此节点没有可配置的属性",
|
||||
"apply": "应用",
|
||||
"reset": "重置",
|
||||
"addVariable": "添加变量",
|
||||
"variableName": "变量名",
|
||||
"type": "类型",
|
||||
"value": "值",
|
||||
"defaultGroup": "默认分组",
|
||||
"rootNode": "根节点",
|
||||
"rootNodeOnlyOneChild": "根节点只能连接一个子节点",
|
||||
"dragToCreate": "从左侧拖拽节点到根节点下方开始创建行为树",
|
||||
"connectFirst": "先连接根节点与第一个节点",
|
||||
"nodeCount": "节点数",
|
||||
"noSelection": "未选择节点",
|
||||
"selectedCount": "已选择 {{count}} 个节点",
|
||||
"idle": "空闲",
|
||||
"running": "运行中",
|
||||
"paused": "已暂停",
|
||||
"step": "单步",
|
||||
"run": "运行",
|
||||
"pause": "暂停",
|
||||
"resume": "继续",
|
||||
"stop": "停止",
|
||||
"stepExecution": "单步执行",
|
||||
"resetExecution": "重置",
|
||||
"clear": "清空",
|
||||
"resetView": "重置视图",
|
||||
"tick": "帧",
|
||||
"executing": "执行中",
|
||||
"success": "成功",
|
||||
"failure": "失败",
|
||||
"startingExecution": "从根节点开始执行...",
|
||||
"tickNumber": "第 {{tick}} 帧",
|
||||
"executionStopped": "执行停止,共 {{tick}} 帧",
|
||||
"executionPaused": "执行已暂停",
|
||||
"executionResumed": "执行已恢复",
|
||||
"resetToInitial": "重置到初始状态",
|
||||
"currentValue": "当前值"
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
import './styles/index.css';
|
||||
import './i18n/config';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
183
packages/editor-app/src/plugins/BehaviorTreePlugin.ts
Normal file
183
packages/editor-app/src/plugins/BehaviorTreePlugin.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
|
||||
import { IEditorPlugin, EditorPluginCategory, PanelPosition, MessageHub } from '@esengine/editor-core';
|
||||
import type { MenuItem, ToolbarItem, PanelDescriptor, ISerializer } from '@esengine/editor-core';
|
||||
import { BehaviorTreePersistence } from '@esengine/behavior-tree';
|
||||
|
||||
/**
|
||||
* 行为树编辑器插件
|
||||
*
|
||||
* 提供行为树的可视化编辑功能
|
||||
*/
|
||||
export class BehaviorTreePlugin implements IEditorPlugin {
|
||||
readonly name = '@esengine/behavior-tree-editor';
|
||||
readonly version = '1.0.0';
|
||||
readonly displayName = 'Behavior Tree Editor';
|
||||
readonly category = EditorPluginCategory.Tool;
|
||||
readonly description = 'Visual behavior tree editor for AI development';
|
||||
readonly icon = 'Network';
|
||||
|
||||
private core?: Core;
|
||||
private services?: ServiceContainer;
|
||||
private messageHub?: MessageHub;
|
||||
|
||||
async install(core: Core, services: ServiceContainer): Promise<void> {
|
||||
this.core = core;
|
||||
this.services = services;
|
||||
this.messageHub = services.resolve(MessageHub);
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
this.core = undefined;
|
||||
this.services = undefined;
|
||||
}
|
||||
|
||||
registerMenuItems(): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'view-behavior-tree-editor',
|
||||
label: 'Behavior Tree Editor',
|
||||
parentId: 'window',
|
||||
onClick: () => {
|
||||
this.messageHub?.publish('ui:openWindow', { windowId: 'behavior-tree-editor' });
|
||||
},
|
||||
icon: 'Network',
|
||||
order: 50
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
registerToolbar(): ToolbarItem[] {
|
||||
return [
|
||||
{
|
||||
id: 'toolbar-new-behavior-tree',
|
||||
label: 'New Behavior Tree',
|
||||
groupId: 'behavior-tree-tools',
|
||||
icon: 'FilePlus',
|
||||
onClick: () => this.createNewBehaviorTree(),
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: 'toolbar-save-behavior-tree',
|
||||
label: 'Save Behavior Tree',
|
||||
groupId: 'behavior-tree-tools',
|
||||
icon: 'Save',
|
||||
onClick: () => this.saveBehaviorTree(),
|
||||
order: 20
|
||||
},
|
||||
{
|
||||
id: 'toolbar-validate-behavior-tree',
|
||||
label: 'Validate Behavior Tree',
|
||||
groupId: 'behavior-tree-tools',
|
||||
icon: 'CheckCircle',
|
||||
onClick: () => this.validateBehaviorTree(),
|
||||
order: 30
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
registerPanels(): PanelDescriptor[] {
|
||||
return [
|
||||
{
|
||||
id: 'panel-behavior-tree-editor',
|
||||
title: 'Behavior Tree Editor',
|
||||
position: PanelPosition.Center,
|
||||
resizable: true,
|
||||
closable: true,
|
||||
icon: 'Network',
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: 'panel-behavior-tree-nodes',
|
||||
title: 'Behavior Tree Nodes',
|
||||
position: PanelPosition.Left,
|
||||
defaultSize: 250,
|
||||
resizable: true,
|
||||
closable: true,
|
||||
icon: 'Package',
|
||||
order: 20
|
||||
},
|
||||
{
|
||||
id: 'panel-behavior-tree-properties',
|
||||
title: 'Node Properties',
|
||||
position: PanelPosition.Right,
|
||||
defaultSize: 300,
|
||||
resizable: true,
|
||||
closable: true,
|
||||
icon: 'Settings',
|
||||
order: 20
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
getSerializers(): ISerializer[] {
|
||||
return [
|
||||
{
|
||||
serialize: (data: any) => {
|
||||
// 使用行为树持久化工具
|
||||
const result = BehaviorTreePersistence.serialize(data.entity, data.pretty ?? true);
|
||||
if (typeof result === 'string') {
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(result);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
deserialize: (data: Uint8Array) => {
|
||||
// 返回原始数据,让上层决定如何反序列化到场景
|
||||
return data;
|
||||
},
|
||||
getSupportedType: () => 'behavior-tree'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async onEditorReady(): Promise<void> {
|
||||
console.log('[BehaviorTreePlugin] Editor is ready');
|
||||
}
|
||||
|
||||
async onProjectOpen(projectPath: string): Promise<void> {
|
||||
console.log(`[BehaviorTreePlugin] Project opened: ${projectPath}`);
|
||||
}
|
||||
|
||||
async onProjectClose(): Promise<void> {
|
||||
console.log('[BehaviorTreePlugin] Project closed');
|
||||
}
|
||||
|
||||
async onBeforeSave(filePath: string, data: any): Promise<void> {
|
||||
// 验证行为树数据
|
||||
if (filePath.endsWith('.behavior-tree.json')) {
|
||||
console.log('[BehaviorTreePlugin] Validating behavior tree before save');
|
||||
const isValid = BehaviorTreePersistence.validate(JSON.stringify(data));
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid behavior tree data');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onAfterSave(filePath: string): Promise<void> {
|
||||
if (filePath.endsWith('.behavior-tree.json')) {
|
||||
console.log(`[BehaviorTreePlugin] Behavior tree saved: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 私有方法
|
||||
|
||||
private createNewBehaviorTree(): void {
|
||||
console.log('[BehaviorTreePlugin] Creating new behavior tree');
|
||||
// TODO: 实现创建新行为树的逻辑
|
||||
}
|
||||
|
||||
private openBehaviorTree(): void {
|
||||
console.log('[BehaviorTreePlugin] Opening behavior tree');
|
||||
// TODO: 实现打开行为树的逻辑
|
||||
}
|
||||
|
||||
private saveBehaviorTree(): void {
|
||||
console.log('[BehaviorTreePlugin] Saving behavior tree');
|
||||
// TODO: 实现保存行为树的逻辑
|
||||
}
|
||||
|
||||
private validateBehaviorTree(): void {
|
||||
console.log('[BehaviorTreePlugin] Validating behavior tree');
|
||||
// TODO: 实现验证行为树的逻辑
|
||||
}
|
||||
}
|
||||
451
packages/editor-app/src/stores/behaviorTreeStore.ts
Normal file
451
packages/editor-app/src/stores/behaviorTreeStore.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import { create } from 'zustand';
|
||||
import { NodeTemplate, NodeTemplates, EditorFormatConverter, BehaviorTreeAssetSerializer, NodeType } from '@esengine/behavior-tree';
|
||||
|
||||
interface BehaviorTreeNode {
|
||||
id: string;
|
||||
template: NodeTemplate;
|
||||
data: Record<string, any>;
|
||||
position: { x: number; y: number };
|
||||
children: string[];
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
from: string;
|
||||
to: string;
|
||||
fromProperty?: string;
|
||||
toProperty?: string;
|
||||
connectionType: 'node' | 'property';
|
||||
}
|
||||
|
||||
interface BehaviorTreeState {
|
||||
nodes: BehaviorTreeNode[];
|
||||
connections: Connection[];
|
||||
selectedNodeIds: string[];
|
||||
draggingNodeId: string | null;
|
||||
dragStartPositions: Map<string, { x: number; y: number }>;
|
||||
isDraggingNode: boolean;
|
||||
|
||||
// 黑板变量
|
||||
blackboardVariables: Record<string, any>;
|
||||
// 初始黑板变量(设计时的值,用于保存)
|
||||
initialBlackboardVariables: Record<string, any>;
|
||||
// 是否正在运行行为树
|
||||
isExecuting: boolean;
|
||||
|
||||
// 画布变换
|
||||
canvasOffset: { x: number; y: number };
|
||||
canvasScale: number;
|
||||
isPanning: boolean;
|
||||
panStart: { x: number; y: number };
|
||||
|
||||
// 连接状态
|
||||
connectingFrom: string | null;
|
||||
connectingFromProperty: string | null;
|
||||
connectingToPos: { x: number; y: number } | null;
|
||||
|
||||
// 框选状态
|
||||
isBoxSelecting: boolean;
|
||||
boxSelectStart: { x: number; y: number } | null;
|
||||
boxSelectEnd: { x: number; y: number } | null;
|
||||
|
||||
// 拖动偏移
|
||||
dragDelta: { dx: number; dy: number };
|
||||
|
||||
// 强制更新计数器
|
||||
forceUpdateCounter: number;
|
||||
|
||||
// Actions
|
||||
setNodes: (nodes: BehaviorTreeNode[]) => void;
|
||||
updateNodes: (updater: (nodes: BehaviorTreeNode[]) => BehaviorTreeNode[]) => void;
|
||||
addNode: (node: BehaviorTreeNode) => void;
|
||||
removeNodes: (nodeIds: string[]) => void;
|
||||
updateNodePosition: (nodeId: string, position: { x: number; y: number }) => void;
|
||||
updateNodesPosition: (updates: Map<string, { x: number; y: number }>) => void;
|
||||
|
||||
setConnections: (connections: Connection[]) => void;
|
||||
addConnection: (connection: Connection) => void;
|
||||
removeConnections: (filter: (conn: Connection) => boolean) => void;
|
||||
|
||||
setSelectedNodeIds: (nodeIds: string[]) => void;
|
||||
toggleNodeSelection: (nodeId: string) => void;
|
||||
clearSelection: () => void;
|
||||
|
||||
startDragging: (nodeId: string, startPositions: Map<string, { x: number; y: number }>) => void;
|
||||
stopDragging: () => void;
|
||||
setIsDraggingNode: (isDragging: boolean) => void;
|
||||
|
||||
// 画布变换 Actions
|
||||
setCanvasOffset: (offset: { x: number; y: number }) => void;
|
||||
setCanvasScale: (scale: number) => void;
|
||||
setIsPanning: (isPanning: boolean) => void;
|
||||
setPanStart: (panStart: { x: number; y: number }) => void;
|
||||
resetView: () => void;
|
||||
|
||||
// 连接 Actions
|
||||
setConnectingFrom: (nodeId: string | null) => void;
|
||||
setConnectingFromProperty: (propertyName: string | null) => void;
|
||||
setConnectingToPos: (pos: { x: number; y: number } | null) => void;
|
||||
clearConnecting: () => void;
|
||||
|
||||
// 框选 Actions
|
||||
setIsBoxSelecting: (isSelecting: boolean) => void;
|
||||
setBoxSelectStart: (pos: { x: number; y: number } | null) => void;
|
||||
setBoxSelectEnd: (pos: { x: number; y: number } | null) => void;
|
||||
clearBoxSelect: () => void;
|
||||
|
||||
// 拖动偏移 Actions
|
||||
setDragDelta: (delta: { dx: number; dy: number }) => void;
|
||||
|
||||
// 强制更新
|
||||
triggerForceUpdate: () => void;
|
||||
|
||||
// 黑板变量 Actions
|
||||
setBlackboardVariables: (variables: Record<string, any>) => void;
|
||||
updateBlackboardVariable: (name: string, value: any) => void;
|
||||
setInitialBlackboardVariables: (variables: Record<string, any>) => void;
|
||||
setIsExecuting: (isExecuting: boolean) => void;
|
||||
|
||||
// 自动排序子节点
|
||||
sortChildrenByPosition: () => void;
|
||||
|
||||
// 数据导出/导入
|
||||
exportToJSON: (metadata: { name: string; description: string }, blackboard: Record<string, any>) => string;
|
||||
importFromJSON: (json: string) => { blackboard: Record<string, any> };
|
||||
|
||||
// 运行时资产导出
|
||||
exportToRuntimeAsset: (
|
||||
metadata: { name: string; description: string },
|
||||
blackboard: Record<string, any>,
|
||||
format: 'json' | 'binary'
|
||||
) => string | Uint8Array;
|
||||
|
||||
// 重置所有状态
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const ROOT_NODE_ID = 'root-node';
|
||||
|
||||
// 创建根节点模板
|
||||
const createRootNodeTemplate = (): NodeTemplate => ({
|
||||
type: NodeType.Composite,
|
||||
displayName: '根节点',
|
||||
category: '根节点',
|
||||
icon: 'TreePine',
|
||||
description: '行为树根节点',
|
||||
color: '#FFD700',
|
||||
defaultConfig: {
|
||||
nodeType: 'root'
|
||||
},
|
||||
properties: []
|
||||
});
|
||||
|
||||
// 创建初始根节点
|
||||
const createInitialRootNode = (): BehaviorTreeNode => ({
|
||||
id: ROOT_NODE_ID,
|
||||
template: createRootNodeTemplate(),
|
||||
data: { nodeType: 'root' },
|
||||
position: { x: 400, y: 100 },
|
||||
children: []
|
||||
});
|
||||
|
||||
export const useBehaviorTreeStore = create<BehaviorTreeState>((set, get) => ({
|
||||
nodes: [createInitialRootNode()],
|
||||
connections: [],
|
||||
selectedNodeIds: [],
|
||||
draggingNodeId: null,
|
||||
dragStartPositions: new Map(),
|
||||
isDraggingNode: false,
|
||||
|
||||
// 黑板变量初始值
|
||||
blackboardVariables: {},
|
||||
initialBlackboardVariables: {},
|
||||
isExecuting: false,
|
||||
|
||||
// 画布变换初始值
|
||||
canvasOffset: { x: 0, y: 0 },
|
||||
canvasScale: 1,
|
||||
isPanning: false,
|
||||
panStart: { x: 0, y: 0 },
|
||||
|
||||
// 连接状态初始值
|
||||
connectingFrom: null,
|
||||
connectingFromProperty: null,
|
||||
connectingToPos: null,
|
||||
|
||||
// 框选状态初始值
|
||||
isBoxSelecting: false,
|
||||
boxSelectStart: null,
|
||||
boxSelectEnd: null,
|
||||
|
||||
// 拖动偏移初始值
|
||||
dragDelta: { dx: 0, dy: 0 },
|
||||
|
||||
// 强制更新计数器初始值
|
||||
forceUpdateCounter: 0,
|
||||
|
||||
setNodes: (nodes: BehaviorTreeNode[]) => set({ nodes }),
|
||||
|
||||
updateNodes: (updater: (nodes: BehaviorTreeNode[]) => BehaviorTreeNode[]) => set((state: BehaviorTreeState) => ({ nodes: updater(state.nodes) })),
|
||||
|
||||
addNode: (node: BehaviorTreeNode) => set((state: BehaviorTreeState) => ({ nodes: [...state.nodes, node] })),
|
||||
|
||||
removeNodes: (nodeIds: string[]) => set((state: BehaviorTreeState) => {
|
||||
// 只删除指定的节点,不删除子节点
|
||||
const nodesToDelete = new Set<string>(nodeIds);
|
||||
|
||||
// 过滤掉删除的节点,并清理所有节点的 children 引用
|
||||
const remainingNodes = state.nodes
|
||||
.filter((n: BehaviorTreeNode) => !nodesToDelete.has(n.id))
|
||||
.map((n: BehaviorTreeNode) => ({
|
||||
...n,
|
||||
children: n.children.filter((childId: string) => !nodesToDelete.has(childId))
|
||||
}));
|
||||
|
||||
return { nodes: remainingNodes };
|
||||
}),
|
||||
|
||||
updateNodePosition: (nodeId: string, position: { x: number; y: number }) => set((state: BehaviorTreeState) => ({
|
||||
nodes: state.nodes.map((n: BehaviorTreeNode) =>
|
||||
n.id === nodeId ? { ...n, position } : n
|
||||
),
|
||||
})),
|
||||
|
||||
updateNodesPosition: (updates: Map<string, { x: number; y: number }>) => set((state: BehaviorTreeState) => ({
|
||||
nodes: state.nodes.map((node: BehaviorTreeNode) => {
|
||||
const newPos = updates.get(node.id);
|
||||
return newPos ? { ...node, position: newPos } : node;
|
||||
}),
|
||||
})),
|
||||
|
||||
setConnections: (connections: Connection[]) => set({ connections }),
|
||||
|
||||
addConnection: (connection: Connection) => set((state: BehaviorTreeState) => ({
|
||||
connections: [...state.connections, connection],
|
||||
})),
|
||||
|
||||
removeConnections: (filter: (conn: Connection) => boolean) => set((state: BehaviorTreeState) => ({
|
||||
connections: state.connections.filter(filter),
|
||||
})),
|
||||
|
||||
setSelectedNodeIds: (nodeIds: string[]) => set({ selectedNodeIds: nodeIds }),
|
||||
|
||||
toggleNodeSelection: (nodeId: string) => set((state: BehaviorTreeState) => ({
|
||||
selectedNodeIds: state.selectedNodeIds.includes(nodeId)
|
||||
? state.selectedNodeIds.filter((id: string) => id !== nodeId)
|
||||
: [...state.selectedNodeIds, nodeId],
|
||||
})),
|
||||
|
||||
clearSelection: () => set({ selectedNodeIds: [] }),
|
||||
|
||||
startDragging: (nodeId: string, startPositions: Map<string, { x: number; y: number }>) => set({
|
||||
draggingNodeId: nodeId,
|
||||
dragStartPositions: startPositions,
|
||||
}),
|
||||
|
||||
stopDragging: () => set({ draggingNodeId: null }),
|
||||
|
||||
setIsDraggingNode: (isDragging: boolean) => set({ isDraggingNode: isDragging }),
|
||||
|
||||
// 画布变换 Actions
|
||||
setCanvasOffset: (offset: { x: number; y: number }) => set({ canvasOffset: offset }),
|
||||
|
||||
setCanvasScale: (scale: number) => set({ canvasScale: scale }),
|
||||
|
||||
setIsPanning: (isPanning: boolean) => set({ isPanning }),
|
||||
|
||||
setPanStart: (panStart: { x: number; y: number }) => set({ panStart }),
|
||||
|
||||
resetView: () => set({ canvasOffset: { x: 0, y: 0 }, canvasScale: 1 }),
|
||||
|
||||
// 连接 Actions
|
||||
setConnectingFrom: (nodeId: string | null) => set({ connectingFrom: nodeId }),
|
||||
|
||||
setConnectingFromProperty: (propertyName: string | null) => set({ connectingFromProperty: propertyName }),
|
||||
|
||||
setConnectingToPos: (pos: { x: number; y: number } | null) => set({ connectingToPos: pos }),
|
||||
|
||||
clearConnecting: () => set({
|
||||
connectingFrom: null,
|
||||
connectingFromProperty: null,
|
||||
connectingToPos: null,
|
||||
}),
|
||||
|
||||
// 框选 Actions
|
||||
setIsBoxSelecting: (isSelecting: boolean) => set({ isBoxSelecting: isSelecting }),
|
||||
|
||||
setBoxSelectStart: (pos: { x: number; y: number } | null) => set({ boxSelectStart: pos }),
|
||||
|
||||
setBoxSelectEnd: (pos: { x: number; y: number } | null) => set({ boxSelectEnd: pos }),
|
||||
|
||||
clearBoxSelect: () => set({
|
||||
isBoxSelecting: false,
|
||||
boxSelectStart: null,
|
||||
boxSelectEnd: null,
|
||||
}),
|
||||
|
||||
// 拖动偏移 Actions
|
||||
setDragDelta: (delta: { dx: number; dy: number }) => set({ dragDelta: delta }),
|
||||
|
||||
// 强制更新
|
||||
triggerForceUpdate: () => set((state: BehaviorTreeState) => ({ forceUpdateCounter: state.forceUpdateCounter + 1 })),
|
||||
|
||||
// 黑板变量 Actions
|
||||
setBlackboardVariables: (variables: Record<string, any>) => set({ blackboardVariables: variables }),
|
||||
|
||||
updateBlackboardVariable: (name: string, value: any) => set((state: BehaviorTreeState) => ({
|
||||
blackboardVariables: {
|
||||
...state.blackboardVariables,
|
||||
[name]: value
|
||||
}
|
||||
})),
|
||||
|
||||
setInitialBlackboardVariables: (variables: Record<string, any>) => set({ initialBlackboardVariables: variables }),
|
||||
|
||||
setIsExecuting: (isExecuting: boolean) => set({ isExecuting }),
|
||||
|
||||
// 自动排序子节点(按X坐标从左到右)
|
||||
sortChildrenByPosition: () => set((state: BehaviorTreeState) => {
|
||||
const nodeMap = new Map<string, BehaviorTreeNode>();
|
||||
state.nodes.forEach(node => nodeMap.set(node.id, node));
|
||||
|
||||
const sortedNodes = state.nodes.map(node => {
|
||||
if (node.children.length <= 1) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const sortedChildren = [...node.children].sort((a, b) => {
|
||||
const nodeA = nodeMap.get(a);
|
||||
const nodeB = nodeMap.get(b);
|
||||
if (!nodeA || !nodeB) return 0;
|
||||
return nodeA.position.x - nodeB.position.x;
|
||||
});
|
||||
|
||||
return { ...node, children: sortedChildren };
|
||||
});
|
||||
|
||||
return { nodes: sortedNodes };
|
||||
}),
|
||||
|
||||
exportToJSON: (metadata: { name: string; description: string }, blackboard: Record<string, any>) => {
|
||||
const state = get();
|
||||
const now = new Date().toISOString();
|
||||
const data = {
|
||||
version: '1.0.0',
|
||||
metadata: {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
createdAt: now,
|
||||
modifiedAt: now
|
||||
},
|
||||
nodes: state.nodes,
|
||||
connections: state.connections,
|
||||
blackboard: blackboard,
|
||||
canvasState: {
|
||||
offset: state.canvasOffset,
|
||||
scale: state.canvasScale
|
||||
}
|
||||
};
|
||||
return JSON.stringify(data, null, 2);
|
||||
},
|
||||
|
||||
importFromJSON: (json: string) => {
|
||||
const data = JSON.parse(json);
|
||||
const blackboard = data.blackboard || {};
|
||||
|
||||
// 重新关联最新模板:根据 className 从模板库查找
|
||||
const loadedNodes: BehaviorTreeNode[] = (data.nodes || []).map((node: any) => {
|
||||
// 如果是根节点,使用根节点模板
|
||||
if (node.id === ROOT_NODE_ID) {
|
||||
return {
|
||||
...node,
|
||||
template: createRootNodeTemplate()
|
||||
};
|
||||
}
|
||||
|
||||
// 查找最新模板
|
||||
const className = node.template?.className;
|
||||
if (className) {
|
||||
const allTemplates = NodeTemplates.getAllTemplates();
|
||||
const latestTemplate = allTemplates.find(t => t.className === className);
|
||||
|
||||
if (latestTemplate) {
|
||||
return {
|
||||
...node,
|
||||
template: latestTemplate // 使用最新模板
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找不到,保留旧模板(兼容性)
|
||||
return node;
|
||||
});
|
||||
|
||||
set({
|
||||
nodes: loadedNodes,
|
||||
connections: data.connections || [],
|
||||
blackboardVariables: blackboard,
|
||||
canvasOffset: data.canvasState?.offset || { x: 0, y: 0 },
|
||||
canvasScale: data.canvasState?.scale || 1
|
||||
});
|
||||
return { blackboard };
|
||||
},
|
||||
|
||||
exportToRuntimeAsset: (
|
||||
metadata: { name: string; description: string },
|
||||
blackboard: Record<string, any>,
|
||||
format: 'json' | 'binary'
|
||||
) => {
|
||||
const state = get();
|
||||
|
||||
// 构建编辑器格式数据
|
||||
const editorFormat = {
|
||||
version: '1.0.0',
|
||||
metadata: {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString()
|
||||
},
|
||||
nodes: state.nodes,
|
||||
connections: state.connections,
|
||||
blackboard: blackboard
|
||||
};
|
||||
|
||||
// 转换为资产格式
|
||||
const asset = EditorFormatConverter.toAsset(editorFormat, metadata);
|
||||
|
||||
// 序列化为指定格式
|
||||
return BehaviorTreeAssetSerializer.serialize(asset, {
|
||||
format,
|
||||
pretty: format === 'json',
|
||||
validate: true
|
||||
});
|
||||
},
|
||||
|
||||
reset: () => set({
|
||||
nodes: [createInitialRootNode()],
|
||||
connections: [],
|
||||
selectedNodeIds: [],
|
||||
draggingNodeId: null,
|
||||
dragStartPositions: new Map(),
|
||||
isDraggingNode: false,
|
||||
blackboardVariables: {},
|
||||
initialBlackboardVariables: {},
|
||||
isExecuting: false,
|
||||
canvasOffset: { x: 0, y: 0 },
|
||||
canvasScale: 1,
|
||||
isPanning: false,
|
||||
panStart: { x: 0, y: 0 },
|
||||
connectingFrom: null,
|
||||
connectingFromProperty: null,
|
||||
connectingToPos: null,
|
||||
isBoxSelecting: false,
|
||||
boxSelectStart: null,
|
||||
boxSelectEnd: null,
|
||||
dragDelta: { dx: 0, dy: 0 },
|
||||
forceUpdateCounter: 0
|
||||
})
|
||||
}));
|
||||
|
||||
export type { BehaviorTreeNode, Connection };
|
||||
export { ROOT_NODE_ID };
|
||||
@@ -34,6 +34,8 @@
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 100;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editor-header.remote-connected {
|
||||
@@ -162,6 +164,7 @@
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
|
||||
@@ -68,6 +68,43 @@
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
container-type: inline-size;
|
||||
container-name: asset-list-container;
|
||||
}
|
||||
|
||||
.asset-browser-breadcrumb {
|
||||
padding: 8px 12px;
|
||||
background: #2d2d30;
|
||||
border-bottom: 1px solid #3e3e3e;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
font-size: var(--font-size-sm);
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: #cccccc;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
background: #3e3e3e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.breadcrumb-item:active {
|
||||
background: #094771;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: #858585;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.asset-browser-toolbar {
|
||||
@@ -109,13 +146,78 @@
|
||||
.asset-list {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* 容器查询:根据容器宽度调整布局 */
|
||||
@container (max-width: 400px) {
|
||||
.asset-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 250px) {
|
||||
.asset-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 150px) {
|
||||
.asset-list {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.asset-item {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
padding: 6px 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.asset-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.asset-name {
|
||||
text-align: left;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.asset-type {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 中等窄度优化 */
|
||||
@container (max-width: 250px) {
|
||||
.asset-item {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.asset-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.asset-name {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.asset-type {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.asset-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -198,13 +300,3 @@
|
||||
background: #4e4e4e;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.asset-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
}
|
||||
|
||||
.asset-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
397
packages/editor-app/src/styles/AssetPickerDialog.css
Normal file
397
packages/editor-app/src/styles/AssetPickerDialog.css
Normal file
@@ -0,0 +1,397 @@
|
||||
.asset-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.asset-picker-dialog {
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
width: 700px;
|
||||
max-width: 90vw;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
animation: slideUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.asset-picker-header {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.asset-picker-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #cccccc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.asset-picker-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #cccccc;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.asset-picker-close:hover {
|
||||
background-color: #3c3c3c;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.asset-picker-toolbar {
|
||||
padding: 10px 15px;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
background: none;
|
||||
border: 1px solid #444;
|
||||
color: #cccccc;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.toolbar-button:hover:not(:disabled) {
|
||||
background-color: #3c3c3c;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.toolbar-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar-button.active {
|
||||
background-color: #0e639c;
|
||||
border-color: #0e639c;
|
||||
}
|
||||
|
||||
.view-mode-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 面包屑导航 */
|
||||
.asset-picker-breadcrumb {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.asset-picker-breadcrumb::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.asset-picker-breadcrumb::-webkit-scrollbar-thumb {
|
||||
background-color: #555;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.asset-picker-breadcrumb .breadcrumb-item {
|
||||
cursor: pointer;
|
||||
color: #0e639c;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.asset-picker-breadcrumb .breadcrumb-item:hover {
|
||||
color: #1177bb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.asset-picker-breadcrumb .breadcrumb-separator {
|
||||
color: #666;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.asset-picker-search {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
.asset-picker-search .search-icon {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
color: #cccccc;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #0e639c;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
background-color: #3c3c3c;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.asset-picker-content {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
min-height: 350px;
|
||||
}
|
||||
|
||||
.asset-picker-content::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.asset-picker-content::-webkit-scrollbar-track {
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
.asset-picker-content::-webkit-scrollbar-thumb {
|
||||
background-color: #555;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.asset-picker-content::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #666;
|
||||
}
|
||||
|
||||
.asset-picker-loading,
|
||||
.asset-picker-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 列表视图 */
|
||||
.asset-picker-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.asset-picker-list.list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.asset-picker-list.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 资产项 */
|
||||
.asset-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #cccccc;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.asset-picker-list.list .asset-picker-item {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.asset-picker-list.grid .asset-picker-item {
|
||||
flex-direction: column;
|
||||
padding: 15px 10px;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.asset-picker-item:hover {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.asset-picker-item.selected {
|
||||
background-color: #0e639c;
|
||||
}
|
||||
|
||||
.asset-picker-item.selected:hover {
|
||||
background-color: #1177bb;
|
||||
}
|
||||
|
||||
.asset-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asset-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.asset-picker-list.grid .asset-info {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.asset-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.asset-picker-list.grid .asset-name {
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.asset-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.asset-size,
|
||||
.asset-date {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.asset-picker-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: #252525;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.footer-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.asset-picker-cancel,
|
||||
.asset-picker-select {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.asset-picker-cancel {
|
||||
background-color: #3c3c3c;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.asset-picker-cancel:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.asset-picker-select {
|
||||
background-color: #0e639c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.asset-picker-select:hover:not(:disabled) {
|
||||
background-color: #1177bb;
|
||||
}
|
||||
|
||||
.asset-picker-select:disabled {
|
||||
background-color: #555;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
138
packages/editor-app/src/styles/BehaviorTreeNameDialog.css
Normal file
138
packages/editor-app/src/styles/BehaviorTreeNameDialog.css
Normal file
@@ -0,0 +1,138 @@
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
background: var(--bg-secondary, #2a2a2a);
|
||||
border: 1px solid var(--border-color, #404040);
|
||||
border-radius: 8px;
|
||||
min-width: 450px;
|
||||
max-width: 600px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color, #404040);
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialog-body label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dialog-body input[type="text"] {
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-primary, #1e1e1e);
|
||||
border: 1px solid var(--border-color, #404040);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dialog-body input[type="text"]:focus {
|
||||
border-color: #4a9eff;
|
||||
box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.dialog-error {
|
||||
color: #ff6b6b;
|
||||
font-size: 13px;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.dialog-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #999);
|
||||
margin-top: -4px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-color, #404040);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-button {
|
||||
padding: 8px 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dialog-button-secondary {
|
||||
background: var(--bg-hover, #3a3a3a);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.dialog-button-secondary:hover {
|
||||
background: var(--bg-active, #4a4a4a);
|
||||
}
|
||||
|
||||
.dialog-button-primary {
|
||||
background: #4a9eff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dialog-button-primary:hover {
|
||||
background: #6bb0ff;
|
||||
}
|
||||
|
||||
.dialog-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
309
packages/editor-app/src/styles/BehaviorTreeNode.css
Normal file
309
packages/editor-app/src/styles/BehaviorTreeNode.css
Normal file
@@ -0,0 +1,309 @@
|
||||
.bt-node {
|
||||
position: absolute;
|
||||
min-width: 200px;
|
||||
background: #252526;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
overflow: visible;
|
||||
user-select: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bt-node.selected {
|
||||
box-shadow: 0 0 0 2px #007acc, 0 4px 16px rgba(0, 122, 204, 0.4);
|
||||
}
|
||||
|
||||
.bt-node.running {
|
||||
box-shadow: 0 0 0 3px #ffa726, 0 4px 16px rgba(255, 167, 38, 0.6);
|
||||
animation: gentle-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.bt-node.success {
|
||||
box-shadow: 0 0 0 3px #4caf50, 0 4px 16px rgba(76, 175, 80, 0.6);
|
||||
}
|
||||
|
||||
.bt-node.failure {
|
||||
box-shadow: 0 0 0 3px #f44336, 0 4px 16px rgba(244, 67, 54, 0.6);
|
||||
}
|
||||
|
||||
.bt-node.executed {
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.3), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.bt-node.root {
|
||||
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
.bt-node-header {
|
||||
position: relative;
|
||||
padding: 10px 14px;
|
||||
background: linear-gradient(135deg, #4a148c 0%, #7b1fa2 100%);
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.bt-node-header.composite {
|
||||
background: linear-gradient(135deg, #1565c0 0%, #1976d2 100%);
|
||||
}
|
||||
|
||||
.bt-node-header.action {
|
||||
background: linear-gradient(135deg, #2e7d32 0%, #388e3c 100%);
|
||||
}
|
||||
|
||||
.bt-node-header.condition {
|
||||
background: linear-gradient(135deg, #c62828 0%, #d32f2f 100%);
|
||||
}
|
||||
|
||||
.bt-node-header.decorator {
|
||||
background: linear-gradient(135deg, #f57c00 0%, #fb8c00 100%);
|
||||
}
|
||||
|
||||
.bt-node-header.root {
|
||||
background: linear-gradient(135deg, #f9a825 0%, #fdd835 100%);
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.bt-node-header.blackboard {
|
||||
background: linear-gradient(135deg, #6a1b9a 0%, #8e24aa 100%);
|
||||
}
|
||||
|
||||
.bt-node-header-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bt-node-header-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bt-node-body {
|
||||
padding: 12px;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.bt-node-category {
|
||||
font-size: 10px;
|
||||
color: #858585;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bt-node-properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bt-node-property {
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
font-size: 11px;
|
||||
color: #cccccc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.bt-node-property-label {
|
||||
color: #969696;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.bt-node-property-value {
|
||||
color: #cccccc;
|
||||
background: #2d2d30;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.bt-node-port {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #1e1e1e;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: all 0.15s ease;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.bt-node-port-input {
|
||||
background: #0e639c;
|
||||
top: -7px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.bt-node-port-input:hover {
|
||||
transform: translateX(-50%) scale(1.2);
|
||||
}
|
||||
|
||||
.bt-node-port-output {
|
||||
background: #0e639c;
|
||||
bottom: -7px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.bt-node-port-output:hover {
|
||||
transform: translateX(-50%) scale(1.2);
|
||||
}
|
||||
|
||||
.bt-node-port-property {
|
||||
background: #666;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
left: -6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.bt-node-port-property:hover {
|
||||
transform: translateY(-50%) scale(1.2);
|
||||
}
|
||||
|
||||
.bt-node-port-property.connected {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.bt-node-port-variable-output {
|
||||
background: #9c27b0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
right: -6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.bt-node-port-variable-output:hover {
|
||||
transform: translateY(-50%) scale(1.2);
|
||||
}
|
||||
|
||||
.bt-node-blackboard-value {
|
||||
font-size: 11px;
|
||||
color: #cccccc;
|
||||
margin-top: 6px;
|
||||
padding: 6px 8px;
|
||||
background: #2d2d30;
|
||||
border-radius: 3px;
|
||||
border-left: 3px solid #9c27b0;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-50%, -50%) scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 3px #ffa726, 0 4px 16px rgba(255, 167, 38, 0.6);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px #ffa726, 0 6px 20px rgba(255, 167, 38, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.bt-node-empty-warning-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.bt-node-empty-warning-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-top-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.bt-node-empty-warning-container:hover .bt-node-empty-warning-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 未生效节点样式 */
|
||||
.bt-node.uncommitted {
|
||||
border: 2px dashed #ff5722;
|
||||
box-shadow: 0 0 0 2px rgba(255, 87, 34, 0.3), 0 4px 16px rgba(255, 87, 34, 0.4);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.bt-node.uncommitted.selected {
|
||||
box-shadow: 0 0 0 2px #ff5722, 0 4px 16px rgba(255, 87, 34, 0.6);
|
||||
}
|
||||
|
||||
/* 节点ID样式 */
|
||||
.bt-node-id {
|
||||
margin-top: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: inline-block;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* 未生效节点警告 */
|
||||
.bt-node-uncommitted-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 87, 34, 0.95);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.bt-node-uncommitted-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-top-color: rgba(255, 87, 34, 0.95);
|
||||
}
|
||||
|
||||
.bt-node-uncommitted-warning:hover .bt-node-uncommitted-tooltip {
|
||||
display: block;
|
||||
}
|
||||
186
packages/editor-app/src/styles/BehaviorTreeWindow.css
Normal file
186
packages/editor-app/src/styles/BehaviorTreeWindow.css
Normal file
@@ -0,0 +1,186 @@
|
||||
.behavior-tree-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.behavior-tree-window {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.behavior-tree-window.fullscreen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.behavior-tree-header {
|
||||
padding: 12px 20px;
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #1e1e1e;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.behavior-tree-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #cccccc;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.behavior-tree-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.behavior-tree-toolbar-btn {
|
||||
padding: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid #3e3e42;
|
||||
border-radius: 3px;
|
||||
color: #cccccc;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.behavior-tree-toolbar-btn:hover {
|
||||
background: #383838;
|
||||
border-color: #007acc;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.behavior-tree-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.behavior-tree-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.behavior-tree-panel-left {
|
||||
width: 280px;
|
||||
border-right: 2px solid #3e3e42;
|
||||
}
|
||||
|
||||
.behavior-tree-panel-center {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.behavior-tree-panel-right {
|
||||
width: 320px;
|
||||
border-left: 2px solid #3e3e42;
|
||||
}
|
||||
|
||||
.behavior-tree-panel-header {
|
||||
padding: 12px 15px;
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #1e1e1e;
|
||||
color: #cccccc;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.behavior-tree-panel-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.behavior-tree-tabs {
|
||||
display: flex;
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #1e1e1e;
|
||||
}
|
||||
|
||||
.behavior-tree-tab {
|
||||
flex: 1;
|
||||
padding: 10px 15px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #969696;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.behavior-tree-tab::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: transparent;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.behavior-tree-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.behavior-tree-tab.active {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.behavior-tree-tab.active::after {
|
||||
background: #007acc;
|
||||
}
|
||||
|
||||
.behavior-tree-splitter {
|
||||
background: linear-gradient(to right, transparent, #3e3e42 40%, #3e3e42 60%, transparent);
|
||||
transition: all 0.15s ease;
|
||||
cursor: col-resize;
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.behavior-tree-splitter.horizontal {
|
||||
background: linear-gradient(to bottom, transparent, #3e3e42 40%, #3e3e42 60%, transparent);
|
||||
cursor: row-resize;
|
||||
height: 4px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.behavior-tree-splitter:hover {
|
||||
background: #007acc;
|
||||
}
|
||||
58
packages/editor-app/src/styles/ContextMenu.css
Normal file
58
packages/editor-app/src/styles/ContextMenu.css
Normal file
@@ -0,0 +1,58 @@
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
background: #2d2d30;
|
||||
border: 1px solid #3e3e42;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
padding: 4px 0;
|
||||
min-width: 180px;
|
||||
z-index: 10000;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
color: #cccccc;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.context-menu-item:hover:not(.disabled) {
|
||||
background-color: #383838;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.context-menu-item.disabled {
|
||||
color: #666666;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.context-menu-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.context-menu-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.context-menu-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-menu-separator {
|
||||
height: 1px;
|
||||
background-color: #3e3e42;
|
||||
margin: 4px 0;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
.dock-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dock-left,
|
||||
.dock-right,
|
||||
.dock-top,
|
||||
.dock-bottom,
|
||||
.dock-center {
|
||||
background: var(--color-bg-base);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dock-left {
|
||||
border-right: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.dock-right {
|
||||
border-left: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.dock-top {
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.dock-bottom {
|
||||
border-top: 1px solid var(--color-border-default);
|
||||
}
|
||||
371
packages/editor-app/src/styles/ExportRuntimeDialog.css
Normal file
371
packages/editor-app/src/styles/ExportRuntimeDialog.css
Normal file
@@ -0,0 +1,371 @@
|
||||
.export-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.export-dialog {
|
||||
background: #1e1e1e;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
width: 90%;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.export-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.export-dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.export-dialog-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.export-dialog-close:hover {
|
||||
background: #3a3a3a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.export-dialog-content {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.export-dialog-content::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.export-dialog-content::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.export-dialog-content::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.export-dialog-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
|
||||
.export-dialog-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(66, 153, 225, 0.1);
|
||||
border: 1px solid rgba(66, 153, 225, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #4299e1;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.export-format-options {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.export-format-option {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
border: 2px solid #3a3a3a;
|
||||
border-radius: 12px;
|
||||
background: #252525;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.export-format-option:hover {
|
||||
border-color: #4a4a4a;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.export-format-option.selected {
|
||||
border-color: #4299e1;
|
||||
background: rgba(66, 153, 225, 0.1);
|
||||
}
|
||||
|
||||
.export-format-icon {
|
||||
color: #4299e1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: rgba(66, 153, 225, 0.1);
|
||||
border-radius: 12px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.export-format-option.selected .export-format-icon {
|
||||
background: rgba(66, 153, 225, 0.2);
|
||||
}
|
||||
|
||||
.export-format-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.export-format-info h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.export-format-desc {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.export-format-features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: #3a3a3a;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.feature-tag.recommended {
|
||||
background: rgba(72, 187, 120, 0.2);
|
||||
color: #48bb78;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.export-format-option.selected .feature-tag {
|
||||
background: rgba(66, 153, 225, 0.15);
|
||||
color: #4299e1;
|
||||
}
|
||||
|
||||
.export-format-option.selected .feature-tag.recommended {
|
||||
background: rgba(72, 187, 120, 0.2);
|
||||
color: #48bb78;
|
||||
}
|
||||
|
||||
.export-format-extension {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #4299e1;
|
||||
padding: 6px 12px;
|
||||
background: rgba(66, 153, 225, 0.1);
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.export-dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #3a3a3a;
|
||||
background: #252525;
|
||||
}
|
||||
|
||||
.export-dialog-btn {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.export-dialog-btn-cancel {
|
||||
background: transparent;
|
||||
color: #999;
|
||||
border: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.export-dialog-btn-cancel:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: #4a4a4a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.export-dialog-btn-primary {
|
||||
background: #4299e1;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.export-dialog-btn-primary:hover {
|
||||
background: #3182ce;
|
||||
}
|
||||
|
||||
.export-dialog-btn-primary:active {
|
||||
background: #2c5282;
|
||||
}
|
||||
|
||||
.export-mode-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
margin: -24px -24px 20px -24px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.export-mode-tab {
|
||||
padding: 12px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-mode-tab:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.export-mode-tab.active {
|
||||
color: #4299e1;
|
||||
border-bottom-color: #4299e1;
|
||||
}
|
||||
|
||||
.export-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.export-section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 13px;
|
||||
color: #ccc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.export-file-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: #252525;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.export-file-list::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.export-file-list::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.export-file-list::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.export-file-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #4e4e4e;
|
||||
}
|
||||
|
||||
.export-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.export-file-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.export-file-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.export-file-item.selected {
|
||||
background: rgba(66, 153, 225, 0.1);
|
||||
}
|
||||
|
||||
.export-file-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.export-file-name {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.export-file-format {
|
||||
padding: 4px 8px;
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
color: #ccc;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.export-file-format:hover {
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.export-file-format:focus {
|
||||
border-color: #4299e1;
|
||||
}
|
||||
302
packages/editor-app/src/styles/FlexLayoutDock.css
Normal file
302
packages/editor-app/src/styles/FlexLayoutDock.css
Normal file
@@ -0,0 +1,302 @@
|
||||
.flexlayout-dock-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #1e1e1e;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flexlayout__layout {
|
||||
background: #1e1e1e;
|
||||
position: absolute !important;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.flexlayout__tabset {
|
||||
background: #252526;
|
||||
}
|
||||
|
||||
.flexlayout__tabset_header {
|
||||
background: #252526;
|
||||
border-bottom: 1px solid #1e1e1e;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.flexlayout__tab {
|
||||
background: transparent;
|
||||
color: #969696;
|
||||
border: none;
|
||||
padding: 0 16px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
cursor: default;
|
||||
transition: all 0.1s ease;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flexlayout__tab::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: transparent;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.flexlayout__tab:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.flexlayout__tab_button {
|
||||
background: transparent !important;
|
||||
color: #969696;
|
||||
border: none !important;
|
||||
border-right: none !important;
|
||||
padding: 0 16px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flexlayout__tab_button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: transparent;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.flexlayout__tab_button:hover {
|
||||
background: rgba(255, 255, 255, 0.03) !important;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.flexlayout__tab_button--selected {
|
||||
background: transparent !important;
|
||||
color: #ffffff !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.flexlayout__tab_button--selected::after {
|
||||
background: #007acc;
|
||||
}
|
||||
|
||||
.flexlayout__tab_button_leading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.flexlayout__tab_button_content {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.flexlayout__tab_button--selected .flexlayout__tab_button_content {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.flexlayout__tab_button_trailing {
|
||||
margin-left: 8px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.flexlayout__tab:hover .flexlayout__tab_button_trailing {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.flexlayout__tab_button_trailing svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
.flexlayout__tab_button_trailing:hover svg {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.flexlayout__tabset_tabbar_outer {
|
||||
background: #2d2d30;
|
||||
}
|
||||
|
||||
.flexlayout__tabset-selected {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.flexlayout__splitter {
|
||||
background: linear-gradient(to right, transparent, #3e3e42 40%, #3e3e42 60%, transparent);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.flexlayout__splitter_horz {
|
||||
background: linear-gradient(to bottom, transparent, #3e3e42 40%, #3e3e42 60%, transparent);
|
||||
}
|
||||
|
||||
.flexlayout__splitter:hover {
|
||||
background: #007acc;
|
||||
}
|
||||
|
||||
.flexlayout__splitter_border {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.flexlayout__outline_rect {
|
||||
border: 2px solid #007acc;
|
||||
box-shadow: 0 0 20px rgba(0, 122, 204, 0.5);
|
||||
background: rgba(0, 122, 204, 0.1);
|
||||
}
|
||||
|
||||
.flexlayout__edge_rect {
|
||||
background: rgba(0, 122, 204, 0.3);
|
||||
border: 2px solid #007acc;
|
||||
}
|
||||
|
||||
.flexlayout__drag_rect {
|
||||
border: 2px solid #007acc;
|
||||
background: rgba(0, 122, 204, 0.2);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.flexlayout__tabset_content {
|
||||
background: #1e1e1e;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.flexlayout__tabset_content * {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.flexlayout__tabset_content button,
|
||||
.flexlayout__tabset_content a,
|
||||
.flexlayout__tabset_content [role="button"] {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.flexlayout__tab_toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.flexlayout__tab_toolbar_button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #cccccc;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.flexlayout__tab_toolbar_button:hover {
|
||||
background: #383838;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.flexlayout__tab_toolbar_button svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.flexlayout__popup_menu {
|
||||
background: #252526;
|
||||
border: 1px solid #454545;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.flexlayout__popup_menu_item {
|
||||
color: #cccccc;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.flexlayout__popup_menu_item:hover {
|
||||
background: #2a2d2e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.flexlayout__popup_menu_item:active {
|
||||
background: #094771;
|
||||
}
|
||||
|
||||
.flexlayout__border {
|
||||
background: #252526;
|
||||
border: 1px solid #1e1e1e;
|
||||
}
|
||||
|
||||
.flexlayout__border_top,
|
||||
.flexlayout__border_bottom {
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.flexlayout__border_left,
|
||||
.flexlayout__border_right {
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.flexlayout__border_button {
|
||||
background: transparent;
|
||||
color: #969696;
|
||||
border: none;
|
||||
border-bottom: 1px solid #1e1e1e;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flexlayout__border_button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: transparent;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.flexlayout__border_button:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.flexlayout__border_button--selected {
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.flexlayout__border_button--selected::after {
|
||||
background: #007acc;
|
||||
}
|
||||
|
||||
.flexlayout__error_boundary_container {
|
||||
background: #1e1e1e;
|
||||
color: #f48771;
|
||||
padding: 16px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.flexlayout__error_boundary_message {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -69,6 +69,12 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.menu-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.menu-shortcut {
|
||||
margin-left: 24px;
|
||||
color: var(--color-text-secondary, #858585);
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
|
||||
.startup-footer {
|
||||
padding: 20px 40px;
|
||||
border-top: 1px solid #2d2d30;
|
||||
border-top: 1px solid #1e1e1e;
|
||||
}
|
||||
|
||||
.startup-version {
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
.tab-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--color-bg-base);
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
background: var(--color-bg-elevated);
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border-right: 1px solid var(--color-border-default);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: transparent;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tab-item.active::after {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
transition: all var(--transition-fast);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tab-item:hover .tab-close,
|
||||
.tab-item.active .tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-error);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
126
packages/editor-app/src/styles/Toast.css
Normal file
126
packages/editor-app/src/styles/Toast.css
Normal file
@@ -0,0 +1,126 @@
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 99999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(26, 26, 26, 0.95);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4), 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(8px);
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
pointer-events: auto;
|
||||
animation: toastSlideIn 0.3s cubic-bezier(0.21, 1.02, 0.73, 1) forwards;
|
||||
border-left: 3px solid var(--toast-color);
|
||||
}
|
||||
|
||||
@keyframes toastSlideIn {
|
||||
from {
|
||||
transform: translateX(calc(100% + 20px));
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--toast-color);
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #e0e0e0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.toast-close:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Toast 类型样式 */
|
||||
.toast-success {
|
||||
--toast-color: #4caf50;
|
||||
border-left-color: #4caf50;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
--toast-color: #f44336;
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
--toast-color: #ff9800;
|
||||
border-left-color: #ff9800;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
--toast-color: #2196f3;
|
||||
border-left-color: #2196f3;
|
||||
}
|
||||
|
||||
/* 移除时的动画 */
|
||||
.toast.removing {
|
||||
animation: toastSlideOut 0.3s cubic-bezier(0.21, 1.02, 0.73, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes toastSlideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(100% + 20px));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.toast-container {
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
21
packages/editor-app/src/types/BehaviorTreeData.ts
Normal file
21
packages/editor-app/src/types/BehaviorTreeData.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { BehaviorTreeNode, Connection } from '../stores/behaviorTreeStore';
|
||||
|
||||
/**
|
||||
* 行为树编辑器数据格式
|
||||
*/
|
||||
export interface EditorBehaviorTreeData {
|
||||
version: string;
|
||||
metadata: {
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
};
|
||||
nodes: BehaviorTreeNode[];
|
||||
connections: Connection[];
|
||||
blackboard: Record<string, any>;
|
||||
canvasState: {
|
||||
offset: { x: number; y: number };
|
||||
scale: number;
|
||||
};
|
||||
}
|
||||
886
packages/editor-app/src/utils/BehaviorTreeExecutor.ts
Normal file
886
packages/editor-app/src/utils/BehaviorTreeExecutor.ts
Normal file
@@ -0,0 +1,886 @@
|
||||
import { World, Entity, Scene, createLogger, Time, Core } from '@esengine/ecs-framework';
|
||||
import {
|
||||
BehaviorTreeNode as BehaviorTreeNodeComponent,
|
||||
BlackboardComponent,
|
||||
ActiveNode,
|
||||
PropertyBindings,
|
||||
LogOutput,
|
||||
RootExecutionSystem,
|
||||
LeafExecutionSystem,
|
||||
DecoratorExecutionSystem,
|
||||
CompositeExecutionSystem,
|
||||
SubTreeExecutionSystem,
|
||||
FileSystemAssetLoader,
|
||||
CompositeNodeComponent,
|
||||
TaskStatus,
|
||||
NodeType,
|
||||
WaitAction,
|
||||
LogAction,
|
||||
SetBlackboardValueAction,
|
||||
ModifyBlackboardValueAction,
|
||||
ExecuteAction,
|
||||
BlackboardCompareCondition,
|
||||
BlackboardExistsCondition,
|
||||
RandomProbabilityCondition,
|
||||
ExecuteCondition,
|
||||
RootNode,
|
||||
SequenceNode,
|
||||
SelectorNode,
|
||||
ParallelNode,
|
||||
ParallelSelectorNode,
|
||||
RandomSequenceNode,
|
||||
RandomSelectorNode,
|
||||
SubTreeNode,
|
||||
InverterNode,
|
||||
RepeaterNode,
|
||||
UntilSuccessNode,
|
||||
UntilFailNode,
|
||||
AlwaysSucceedNode,
|
||||
AlwaysFailNode,
|
||||
ConditionalNode,
|
||||
CooldownNode,
|
||||
TimeoutNode,
|
||||
AbortType,
|
||||
CompareOperator
|
||||
} from '@esengine/behavior-tree';
|
||||
import type { BehaviorTreeNode } from '../stores/behaviorTreeStore';
|
||||
import { TauriAPI } from '../api/tauri';
|
||||
|
||||
const logger = createLogger('BehaviorTreeExecutor');
|
||||
|
||||
export interface ExecutionStatus {
|
||||
nodeId: string;
|
||||
status: 'running' | 'success' | 'failure' | 'idle';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionLog {
|
||||
timestamp: number;
|
||||
message: string;
|
||||
level: 'info' | 'success' | 'error' | 'warning';
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
export type ExecutionCallback = (
|
||||
statuses: ExecutionStatus[],
|
||||
logs: ExecutionLog[],
|
||||
blackboardVariables?: Record<string, any>
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* 真实的行为树执行器
|
||||
*
|
||||
* 使用真实的 ECS 系统执行行为树
|
||||
*/
|
||||
export class BehaviorTreeExecutor {
|
||||
private world: World;
|
||||
private scene: Scene;
|
||||
private rootEntity: Entity | null = null;
|
||||
private entityMap: Map<string, Entity> = new Map();
|
||||
private blackboardVariables: Record<string, any> = {};
|
||||
private initialBlackboardVariables: Record<string, any> = {};
|
||||
private callback: ExecutionCallback | null = null;
|
||||
private isRunning = false;
|
||||
private isPaused = false;
|
||||
private executionLogs: ExecutionLog[] = [];
|
||||
private lastStatuses: Map<string, 'running' | 'success' | 'failure' | 'idle'> = new Map();
|
||||
private debugMode = false;
|
||||
private tickCount = 0;
|
||||
// 存储节点ID -> 属性绑定信息的映射
|
||||
private propertyBindingsMap: Map<string, Map<string, string>> = new Map();
|
||||
// 标记是否已添加 SubTreeExecutionSystem
|
||||
private hasSubTreeSystem = false;
|
||||
|
||||
constructor() {
|
||||
this.world = new World({ name: 'BehaviorTreeWorld' });
|
||||
this.scene = this.world.createScene('BehaviorTreeScene');
|
||||
|
||||
// 只注册基础执行系统
|
||||
this.scene.addSystem(new RootExecutionSystem());
|
||||
this.scene.addSystem(new LeafExecutionSystem());
|
||||
this.scene.addSystem(new DecoratorExecutionSystem());
|
||||
this.scene.addSystem(new CompositeExecutionSystem());
|
||||
// SubTreeExecutionSystem 按需添加
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否有 SubTree 节点
|
||||
*/
|
||||
private detectSubTreeNodes(nodes: BehaviorTreeNode[]): boolean {
|
||||
return nodes.some(node => node.template.displayName === '子树');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从编辑器节点构建真实的 Entity 树
|
||||
*/
|
||||
buildTree(
|
||||
nodes: BehaviorTreeNode[],
|
||||
rootNodeId: string,
|
||||
blackboard: Record<string, any>,
|
||||
connections: Array<{ from: string; to: string; fromProperty?: string; toProperty?: string; connectionType: 'node' | 'property' }>,
|
||||
callback: ExecutionCallback,
|
||||
projectPath?: string | null
|
||||
): void {
|
||||
this.cleanup();
|
||||
this.blackboardVariables = { ...blackboard };
|
||||
this.callback = callback;
|
||||
|
||||
const nodeMap = new Map<string, BehaviorTreeNode>();
|
||||
nodes.forEach(node => nodeMap.set(node.id, node));
|
||||
|
||||
// 检测是否有 SubTree 节点
|
||||
const hasSubTreeNode = this.detectSubTreeNodes(nodes);
|
||||
|
||||
if (hasSubTreeNode) {
|
||||
// 按需添加 SubTreeExecutionSystem
|
||||
if (!this.hasSubTreeSystem) {
|
||||
this.scene.addSystem(new SubTreeExecutionSystem());
|
||||
this.hasSubTreeSystem = true;
|
||||
logger.debug('检测到 SubTree 节点,已添加 SubTreeExecutionSystem');
|
||||
}
|
||||
|
||||
// 配置资产加载器以支持 SubTree 节点
|
||||
if (projectPath) {
|
||||
const assetLoader = new FileSystemAssetLoader({
|
||||
basePath: `${projectPath}/.ecs/behaviors`,
|
||||
format: 'json',
|
||||
extension: '.btree',
|
||||
enableCache: true,
|
||||
readFile: async (path: string) => {
|
||||
const content = await TauriAPI.readFileContent(path);
|
||||
return content;
|
||||
}
|
||||
});
|
||||
|
||||
Core.services.registerInstance(FileSystemAssetLoader, assetLoader);
|
||||
logger.info(`已配置资产加载器: ${projectPath}/.ecs/behaviors`);
|
||||
} else {
|
||||
logger.warn(
|
||||
'检测到 SubTree 节点,但未提供项目路径。\n' +
|
||||
'SubTree 节点需要项目路径来加载子树资产。\n' +
|
||||
'请在编辑器中打开项目,或确保运行时环境已正确配置资产路径。'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rootNode = nodeMap.get(rootNodeId);
|
||||
if (!rootNode) {
|
||||
logger.error('未找到根节点');
|
||||
return;
|
||||
}
|
||||
|
||||
// 先创建黑板组件
|
||||
const blackboardComp = new BlackboardComponent();
|
||||
Object.entries(this.blackboardVariables).forEach(([key, value]) => {
|
||||
const type = typeof value === 'number' ? 'number' :
|
||||
typeof value === 'string' ? 'string' :
|
||||
typeof value === 'boolean' ? 'boolean' :
|
||||
'object';
|
||||
blackboardComp.defineVariable(key, type as any, value);
|
||||
});
|
||||
|
||||
// 在创建实体之前先处理属性连接,这样创建组件时就能读到正确的值
|
||||
this.applyPropertyConnections(connections, nodeMap, blackboardComp);
|
||||
|
||||
// 创建实体树
|
||||
this.rootEntity = this.createEntityFromNode(rootNode, nodeMap, null);
|
||||
|
||||
if (this.rootEntity) {
|
||||
// 将黑板组件添加到根实体
|
||||
this.rootEntity.addComponent(blackboardComp);
|
||||
|
||||
// 添加LogOutput组件,用于收集日志
|
||||
this.rootEntity.addComponent(new LogOutput());
|
||||
|
||||
if (this.debugMode) {
|
||||
this.logDebugTreeStructure();
|
||||
// 立即触发一次回调,显示 debug 信息
|
||||
if (this.callback) {
|
||||
this.callback([], this.executionLogs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用属性连接
|
||||
* 记录属性到黑板变量的绑定关系
|
||||
*/
|
||||
private applyPropertyConnections(
|
||||
connections: Array<{ from: string; to: string; fromProperty?: string; toProperty?: string; connectionType: 'node' | 'property' }>,
|
||||
nodeMap: Map<string, BehaviorTreeNode>,
|
||||
blackboard: BlackboardComponent
|
||||
): void {
|
||||
// 清空之前的绑定信息
|
||||
this.propertyBindingsMap.clear();
|
||||
|
||||
// 过滤出属性类型的连接
|
||||
const propertyConnections = connections.filter(conn => conn.connectionType === 'property');
|
||||
|
||||
logger.info(`[属性绑定] 找到 ${propertyConnections.length} 个属性连接`);
|
||||
|
||||
propertyConnections.forEach(conn => {
|
||||
const fromNode = nodeMap.get(conn.from);
|
||||
const toNode = nodeMap.get(conn.to);
|
||||
|
||||
if (!fromNode || !toNode || !conn.toProperty) {
|
||||
logger.warn(`[属性绑定] 连接数据不完整: from=${conn.from}, to=${conn.to}, toProperty=${conn.toProperty}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let variableName: string | undefined;
|
||||
|
||||
// 检查 from 节点是否是黑板变量节点
|
||||
if (fromNode.data.nodeType === 'blackboard-variable') {
|
||||
// 黑板变量节点,变量名在 data.variableName 中
|
||||
variableName = fromNode.data.variableName;
|
||||
} else if (conn.fromProperty) {
|
||||
// 普通节点的属性连接
|
||||
variableName = conn.fromProperty;
|
||||
}
|
||||
|
||||
if (!variableName) {
|
||||
logger.warn(`[属性绑定] 无法确定变量名: from节点=${fromNode.template.displayName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blackboard.hasVariable(variableName)) {
|
||||
logger.warn(`[属性绑定] 黑板变量不存在: ${variableName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录绑定信息到 Map
|
||||
let nodeBindings = this.propertyBindingsMap.get(toNode.id);
|
||||
if (!nodeBindings) {
|
||||
nodeBindings = new Map<string, string>();
|
||||
this.propertyBindingsMap.set(toNode.id, nodeBindings);
|
||||
}
|
||||
|
||||
nodeBindings.set(conn.toProperty, variableName);
|
||||
|
||||
logger.info(`[属性绑定] 成功绑定: 节点 "${toNode.template.displayName}" 的属性 "${conn.toProperty}" -> 黑板变量 "${variableName}"`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归创建 Entity
|
||||
*/
|
||||
private createEntityFromNode(
|
||||
node: BehaviorTreeNode,
|
||||
nodeMap: Map<string, BehaviorTreeNode>,
|
||||
parent: Entity | null
|
||||
): Entity {
|
||||
const displayName = node.template.displayName || 'Node';
|
||||
const entityName = `${displayName}#${node.id}`;
|
||||
const entity = this.scene.createEntity(entityName);
|
||||
this.entityMap.set(node.id, entity);
|
||||
|
||||
if (parent) {
|
||||
parent.addChild(entity);
|
||||
}
|
||||
|
||||
const btNode = new BehaviorTreeNodeComponent();
|
||||
btNode.nodeType = this.getNodeType(node);
|
||||
entity.addComponent(btNode);
|
||||
|
||||
this.addNodeComponents(entity, node);
|
||||
|
||||
// 检查是否有属性绑定,如果有则添加 PropertyBindings 组件
|
||||
const bindings = this.propertyBindingsMap.get(node.id);
|
||||
if (bindings && bindings.size > 0) {
|
||||
const propertyBindings = new PropertyBindings();
|
||||
bindings.forEach((variableName, propertyName) => {
|
||||
propertyBindings.addBinding(propertyName, variableName);
|
||||
});
|
||||
entity.addComponent(propertyBindings);
|
||||
logger.info(`[PropertyBindings] 为节点 "${node.template.displayName}" 添加了 ${bindings.size} 个属性绑定`);
|
||||
}
|
||||
|
||||
node.children.forEach(childId => {
|
||||
const childNode = nodeMap.get(childId);
|
||||
if (childNode) {
|
||||
this.createEntityFromNode(childNode, nodeMap, entity);
|
||||
}
|
||||
});
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点类型
|
||||
*/
|
||||
private getNodeType(node: BehaviorTreeNode): NodeType {
|
||||
const type = node.template.type;
|
||||
if (type === NodeType.Composite) return NodeType.Composite;
|
||||
if (type === NodeType.Decorator) return NodeType.Decorator;
|
||||
if (type === NodeType.Action) return NodeType.Action;
|
||||
if (type === NodeType.Condition) return NodeType.Condition;
|
||||
return NodeType.Action;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据节点数据添加对应的组件
|
||||
*/
|
||||
private addNodeComponents(entity: Entity, node: BehaviorTreeNode): void {
|
||||
const category = node.template.category;
|
||||
const data = node.data;
|
||||
|
||||
if (category === '根节点' || data.nodeType === 'root') {
|
||||
// 根节点使用专门的 RootNode 组件
|
||||
entity.addComponent(new RootNode());
|
||||
} else if (category === '动作') {
|
||||
this.addActionComponent(entity, node);
|
||||
} else if (category === '条件') {
|
||||
this.addConditionComponent(entity, node);
|
||||
} else if (category === '组合') {
|
||||
this.addCompositeComponent(entity, node);
|
||||
} else if (category === '装饰器') {
|
||||
this.addDecoratorComponent(entity, node);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加动作组件
|
||||
*/
|
||||
private addActionComponent(entity: Entity, node: BehaviorTreeNode): void {
|
||||
const displayName = node.template.displayName;
|
||||
|
||||
if (displayName === '等待') {
|
||||
const action = new WaitAction();
|
||||
action.waitTime = node.data.waitTime ?? 1.0;
|
||||
entity.addComponent(action);
|
||||
} else if (displayName === '日志') {
|
||||
const action = new LogAction();
|
||||
action.message = node.data.message ?? '';
|
||||
action.level = node.data.level ?? 'log';
|
||||
entity.addComponent(action);
|
||||
} else if (displayName === '设置变量') {
|
||||
const action = new SetBlackboardValueAction();
|
||||
action.variableName = node.data.variableName ?? '';
|
||||
action.value = node.data.value;
|
||||
entity.addComponent(action);
|
||||
} else if (displayName === '修改变量') {
|
||||
const action = new ModifyBlackboardValueAction();
|
||||
action.variableName = node.data.variableName ?? '';
|
||||
action.operation = node.data.operation ?? 'add';
|
||||
action.operand = node.data.operand ?? 0;
|
||||
entity.addComponent(action);
|
||||
} else if (displayName === '自定义动作') {
|
||||
const action = new ExecuteAction();
|
||||
action.actionCode = node.data.actionCode ?? 'return TaskStatus.Success;';
|
||||
entity.addComponent(action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加条件组件
|
||||
*/
|
||||
private addConditionComponent(entity: Entity, node: BehaviorTreeNode): void {
|
||||
const displayName = node.template.displayName;
|
||||
|
||||
if (displayName === '比较变量') {
|
||||
const condition = new BlackboardCompareCondition();
|
||||
condition.variableName = node.data.variableName ?? '';
|
||||
condition.operator = (node.data.operator as CompareOperator) ?? CompareOperator.Equal;
|
||||
condition.compareValue = node.data.compareValue;
|
||||
condition.invertResult = node.data.invertResult ?? false;
|
||||
entity.addComponent(condition);
|
||||
} else if (displayName === '变量存在') {
|
||||
const condition = new BlackboardExistsCondition();
|
||||
condition.variableName = node.data.variableName ?? '';
|
||||
condition.checkNotNull = node.data.checkNotNull ?? false;
|
||||
condition.invertResult = node.data.invertResult ?? false;
|
||||
entity.addComponent(condition);
|
||||
} else if (displayName === '随机概率') {
|
||||
const condition = new RandomProbabilityCondition();
|
||||
condition.probability = node.data.probability ?? 0.5;
|
||||
entity.addComponent(condition);
|
||||
} else if (displayName === '执行条件') {
|
||||
const condition = new ExecuteCondition();
|
||||
condition.conditionCode = node.data.conditionCode ?? '';
|
||||
condition.invertResult = node.data.invertResult ?? false;
|
||||
entity.addComponent(condition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加复合节点组件
|
||||
*/
|
||||
private addCompositeComponent(entity: Entity, node: BehaviorTreeNode): void {
|
||||
const displayName = node.template.displayName;
|
||||
|
||||
if (displayName === '序列') {
|
||||
const composite = new SequenceNode();
|
||||
composite.abortType = (node.data.abortType as AbortType) ?? AbortType.None;
|
||||
entity.addComponent(composite);
|
||||
} else if (displayName === '选择') {
|
||||
const composite = new SelectorNode();
|
||||
composite.abortType = (node.data.abortType as AbortType) ?? AbortType.None;
|
||||
entity.addComponent(composite);
|
||||
} else if (displayName === '并行') {
|
||||
const composite = new ParallelNode();
|
||||
composite.successPolicy = node.data.successPolicy ?? 'all';
|
||||
composite.failurePolicy = node.data.failurePolicy ?? 'one';
|
||||
entity.addComponent(composite);
|
||||
} else if (displayName === '并行选择') {
|
||||
const composite = new ParallelSelectorNode();
|
||||
composite.failurePolicy = node.data.failurePolicy ?? 'one';
|
||||
entity.addComponent(composite);
|
||||
} else if (displayName === '随机序列') {
|
||||
const composite = new RandomSequenceNode();
|
||||
entity.addComponent(composite);
|
||||
} else if (displayName === '随机选择') {
|
||||
const composite = new RandomSelectorNode();
|
||||
entity.addComponent(composite);
|
||||
} else if (displayName === '子树') {
|
||||
const composite = new SubTreeNode();
|
||||
composite.assetId = node.data.assetId ?? '';
|
||||
composite.inheritParentBlackboard = node.data.inheritParentBlackboard ?? true;
|
||||
composite.propagateFailure = node.data.propagateFailure ?? true;
|
||||
entity.addComponent(composite);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加装饰器组件
|
||||
*/
|
||||
private addDecoratorComponent(entity: Entity, node: BehaviorTreeNode): void {
|
||||
const displayName = node.template.displayName;
|
||||
|
||||
if (displayName === '反转') {
|
||||
entity.addComponent(new InverterNode());
|
||||
} else if (displayName === '重复') {
|
||||
const decorator = new RepeaterNode();
|
||||
decorator.repeatCount = node.data.repeatCount ?? -1;
|
||||
decorator.endOnFailure = node.data.endOnFailure ?? false;
|
||||
entity.addComponent(decorator);
|
||||
} else if (displayName === '直到成功') {
|
||||
entity.addComponent(new UntilSuccessNode());
|
||||
} else if (displayName === '直到失败') {
|
||||
entity.addComponent(new UntilFailNode());
|
||||
} else if (displayName === '总是成功') {
|
||||
entity.addComponent(new AlwaysSucceedNode());
|
||||
} else if (displayName === '总是失败') {
|
||||
entity.addComponent(new AlwaysFailNode());
|
||||
} else if (displayName === '条件装饰器') {
|
||||
const decorator = new ConditionalNode();
|
||||
decorator.conditionCode = node.data.conditionCode;
|
||||
decorator.shouldReevaluate = node.data.shouldReevaluate ?? true;
|
||||
entity.addComponent(decorator);
|
||||
} else if (displayName === '冷却') {
|
||||
const decorator = new CooldownNode();
|
||||
decorator.cooldownTime = node.data.cooldownTime ?? 1.0;
|
||||
entity.addComponent(decorator);
|
||||
} else if (displayName === '超时') {
|
||||
const decorator = new TimeoutNode();
|
||||
decorator.timeoutDuration = node.data.timeoutDuration ?? 1.0;
|
||||
entity.addComponent(decorator);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始执行
|
||||
*/
|
||||
start(): void {
|
||||
if (!this.rootEntity) {
|
||||
logger.error('未构建行为树');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
this.isPaused = false;
|
||||
this.executionLogs = [];
|
||||
this.lastStatuses.clear();
|
||||
this.tickCount = 0;
|
||||
|
||||
// 保存黑板变量的初始值(深拷贝)
|
||||
this.initialBlackboardVariables = JSON.parse(JSON.stringify(this.blackboardVariables));
|
||||
|
||||
this.addLog('开始执行行为树', 'info');
|
||||
|
||||
// 打印树结构用于调试
|
||||
this.addLog('=== 行为树结构 ===', 'info');
|
||||
this.logEntityStructure(this.rootEntity, 0);
|
||||
this.addLog('===================', 'info');
|
||||
|
||||
this.rootEntity.addComponent(new ActiveNode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停执行
|
||||
*/
|
||||
pause(): void {
|
||||
this.isPaused = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复执行
|
||||
*/
|
||||
resume(): void {
|
||||
this.isPaused = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止执行
|
||||
*/
|
||||
stop(): void {
|
||||
this.isRunning = false;
|
||||
this.isPaused = false;
|
||||
|
||||
if (this.rootEntity) {
|
||||
this.deactivateAllNodes(this.rootEntity);
|
||||
|
||||
// 恢复黑板变量到初始值
|
||||
this.restoreBlackboardVariables();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复黑板变量到初始值
|
||||
*/
|
||||
private restoreBlackboardVariables(): void {
|
||||
if (!this.rootEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blackboard = this.rootEntity.getComponent(BlackboardComponent);
|
||||
if (!blackboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 恢复所有变量到初始值
|
||||
Object.entries(this.initialBlackboardVariables).forEach(([key, value]) => {
|
||||
blackboard.setValue(key, value, true);
|
||||
});
|
||||
|
||||
// 同步到 blackboardVariables
|
||||
this.blackboardVariables = JSON.parse(JSON.stringify(this.initialBlackboardVariables));
|
||||
|
||||
this.addLog('已恢复黑板变量到初始值', 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归停用所有节点
|
||||
*/
|
||||
private deactivateAllNodes(entity: Entity): void {
|
||||
entity.removeComponentByType(ActiveNode);
|
||||
|
||||
const btNode = entity.getComponent(BehaviorTreeNodeComponent);
|
||||
if (btNode) {
|
||||
btNode.reset();
|
||||
}
|
||||
|
||||
entity.children.forEach((child: Entity) => this.deactivateAllNodes(child));
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一帧
|
||||
*/
|
||||
tick(deltaTime: number): void {
|
||||
if (!this.isRunning || this.isPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新全局时间信息
|
||||
Time.update(deltaTime);
|
||||
|
||||
this.tickCount++;
|
||||
|
||||
if (this.debugMode) {
|
||||
this.addLog(`=== Tick ${this.tickCount} ===`, 'info');
|
||||
}
|
||||
|
||||
this.scene.update();
|
||||
|
||||
if (this.debugMode) {
|
||||
this.logDebugSystemExecution();
|
||||
}
|
||||
|
||||
this.collectExecutionStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集所有节点的执行状态
|
||||
*/
|
||||
private collectExecutionStatus(): void {
|
||||
if (!this.callback) return;
|
||||
|
||||
const statuses: ExecutionStatus[] = [];
|
||||
|
||||
this.entityMap.forEach((entity, nodeId) => {
|
||||
const btNode = entity.getComponent(BehaviorTreeNodeComponent);
|
||||
if (!btNode) return;
|
||||
|
||||
let status: 'running' | 'success' | 'failure' | 'idle' = 'idle';
|
||||
|
||||
if (entity.hasComponent(ActiveNode)) {
|
||||
status = 'running';
|
||||
}
|
||||
|
||||
if (btNode.status === TaskStatus.Success) {
|
||||
status = 'success';
|
||||
} else if (btNode.status === TaskStatus.Failure) {
|
||||
status = 'failure';
|
||||
} else if (btNode.status === TaskStatus.Running) {
|
||||
status = 'running';
|
||||
}
|
||||
|
||||
// 检测状态变化并记录日志
|
||||
const lastStatus = this.lastStatuses.get(nodeId);
|
||||
if (lastStatus !== status) {
|
||||
this.onNodeStatusChanged(nodeId, entity.name, lastStatus || 'idle', status, entity);
|
||||
this.lastStatuses.set(nodeId, status);
|
||||
}
|
||||
|
||||
statuses.push({
|
||||
nodeId,
|
||||
status
|
||||
});
|
||||
});
|
||||
|
||||
// 收集LogOutput组件中的日志
|
||||
if (this.rootEntity) {
|
||||
const logOutput = this.rootEntity.getComponent(LogOutput);
|
||||
if (logOutput && logOutput.messages.length > 0) {
|
||||
// 将LogOutput中的日志转换为ExecutionLog格式并添加到日志列表
|
||||
logOutput.messages.forEach((msg) => {
|
||||
this.addLog(
|
||||
msg.message,
|
||||
msg.level === 'error' ? 'error' :
|
||||
msg.level === 'warn' ? 'warning' : 'info'
|
||||
);
|
||||
});
|
||||
// 清空已处理的日志
|
||||
logOutput.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前黑板变量
|
||||
const currentBlackboardVars = this.getBlackboardVariables();
|
||||
|
||||
this.callback(statuses, this.executionLogs, currentBlackboardVars);
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点状态变化时记录日志
|
||||
*/
|
||||
private onNodeStatusChanged(
|
||||
nodeId: string,
|
||||
nodeName: string,
|
||||
oldStatus: string,
|
||||
newStatus: string,
|
||||
entity: Entity
|
||||
): void {
|
||||
if (newStatus === 'running') {
|
||||
this.addLog(`[${nodeName}] 开始执行`, 'info', nodeId);
|
||||
} else if (newStatus === 'success') {
|
||||
// 检查是否是空的复合节点
|
||||
// 排除动态加载子节点的节点(如 SubTreeNode),它们的子节点是运行时动态加载的
|
||||
const btNode = entity.getComponent(BehaviorTreeNodeComponent);
|
||||
const hasDynamicChildren = entity.hasComponent(SubTreeNode);
|
||||
if (btNode && btNode.nodeType === NodeType.Composite &&
|
||||
entity.children.length === 0 && !hasDynamicChildren) {
|
||||
this.addLog(`[${nodeName}] 执行成功(空节点,无子节点)`, 'warning', nodeId);
|
||||
} else {
|
||||
this.addLog(`[${nodeName}] 执行成功`, 'success', nodeId);
|
||||
}
|
||||
} else if (newStatus === 'failure') {
|
||||
this.addLog(`[${nodeName}] 执行失败`, 'error', nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加日志
|
||||
*/
|
||||
private addLog(message: string, level: 'info' | 'success' | 'error' | 'warning', nodeId?: string): void {
|
||||
this.executionLogs.push({
|
||||
timestamp: Date.now(),
|
||||
message,
|
||||
level,
|
||||
nodeId
|
||||
});
|
||||
|
||||
// 限制日志数量,避免内存泄漏
|
||||
if (this.executionLogs.length > 1000) {
|
||||
this.executionLogs.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前tick计数
|
||||
*/
|
||||
getTickCount(): number {
|
||||
return this.tickCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取黑板变量
|
||||
*/
|
||||
getBlackboardVariables(): Record<string, any> {
|
||||
if (!this.rootEntity) return {};
|
||||
|
||||
const blackboard = this.rootEntity.getComponent(BlackboardComponent);
|
||||
if (!blackboard) return {};
|
||||
|
||||
const variables: Record<string, any> = {};
|
||||
const names = blackboard.getVariableNames();
|
||||
names.forEach((name: string) => {
|
||||
variables[name] = blackboard.getValue(name);
|
||||
});
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新黑板变量
|
||||
*/
|
||||
updateBlackboardVariable(key: string, value: any): void {
|
||||
if (!this.rootEntity) {
|
||||
logger.warn('无法更新黑板变量:未构建行为树');
|
||||
return;
|
||||
}
|
||||
|
||||
const blackboard = this.rootEntity.getComponent(BlackboardComponent);
|
||||
if (!blackboard) {
|
||||
logger.warn('无法更新黑板变量:未找到黑板组件');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!blackboard.hasVariable(key)) {
|
||||
logger.warn(`无法更新黑板变量:变量 "${key}" 不存在`);
|
||||
return;
|
||||
}
|
||||
|
||||
blackboard.setValue(key, value);
|
||||
this.blackboardVariables[key] = value;
|
||||
logger.info(`黑板变量已更新: ${key} = ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录树结构的 debug 信息
|
||||
*/
|
||||
private logDebugTreeStructure(): void {
|
||||
if (!this.rootEntity) return;
|
||||
|
||||
this.addLog('=== 行为树结构 Debug 信息 ===', 'info');
|
||||
this.logEntityStructure(this.rootEntity, 0);
|
||||
this.addLog('=== Debug 信息结束 ===', 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归记录实体结构
|
||||
*/
|
||||
private logEntityStructure(entity: Entity, depth: number): void {
|
||||
const indent = ' '.repeat(depth);
|
||||
const nodeId = Array.from(this.entityMap.entries()).find(([_, e]) => e === entity)?.[0] || 'unknown';
|
||||
const btNode = entity.getComponent(BehaviorTreeNodeComponent);
|
||||
|
||||
// 获取节点的具体类型组件
|
||||
const allComponents = entity.components.map(c => c.constructor.name);
|
||||
const nodeTypeComponent = allComponents.find(name =>
|
||||
name !== 'BehaviorTreeNode' && name !== 'ActiveNode' &&
|
||||
name !== 'BlackboardComponent' && name !== 'LogOutput' &&
|
||||
name !== 'PropertyBindings'
|
||||
) || 'Unknown';
|
||||
|
||||
// 构建节点显示名称
|
||||
let nodeName = entity.name;
|
||||
if (nodeTypeComponent !== 'Unknown') {
|
||||
nodeName = `${nodeName} [${nodeTypeComponent}]`;
|
||||
}
|
||||
|
||||
this.addLog(
|
||||
`${indent}└─ ${nodeName} (id: ${nodeId})`,
|
||||
'info'
|
||||
);
|
||||
|
||||
// 检查是否是 SubTreeNode,如果是则显示子树内部结构
|
||||
const subTreeNode = entity.getComponent(SubTreeNode);
|
||||
if (subTreeNode) {
|
||||
const subTreeRoot = subTreeNode.getSubTreeRoot();
|
||||
if (subTreeRoot) {
|
||||
this.addLog(`${indent} [SubTree] 资产ID: ${subTreeNode.assetId}`, 'info');
|
||||
this.addLog(`${indent} [SubTree] 内部结构:`, 'info');
|
||||
this.logEntityStructure(subTreeRoot, depth + 1);
|
||||
} else {
|
||||
this.addLog(`${indent} [SubTree] 资产ID: ${subTreeNode.assetId} (未加载)`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
if (entity.children.length > 0) {
|
||||
this.addLog(`${indent} 子节点数: ${entity.children.length}`, 'info');
|
||||
entity.children.forEach((child: Entity) => {
|
||||
this.logEntityStructure(child, depth + 1);
|
||||
});
|
||||
} else if (btNode && (btNode.nodeType === NodeType.Decorator || btNode.nodeType === NodeType.Composite)) {
|
||||
// SubTreeNode 是特殊情况,不需要静态子节点
|
||||
if (!subTreeNode) {
|
||||
this.addLog(`${indent} ⚠ 警告: 此节点应该有子节点`, 'warning');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录系统执行的 debug 信息
|
||||
*/
|
||||
private logDebugSystemExecution(): void {
|
||||
if (!this.rootEntity) return;
|
||||
|
||||
const activeEntities: Entity[] = [];
|
||||
this.entityMap.forEach((entity) => {
|
||||
if (entity.hasComponent(ActiveNode)) {
|
||||
activeEntities.push(entity);
|
||||
}
|
||||
});
|
||||
|
||||
if (activeEntities.length > 0) {
|
||||
this.addLog(`活跃节点数: ${activeEntities.length}`, 'info');
|
||||
activeEntities.forEach((entity) => {
|
||||
const nodeId = Array.from(this.entityMap.entries()).find(([_, e]) => e === entity)?.[0];
|
||||
const btNode = entity.getComponent(BehaviorTreeNodeComponent);
|
||||
|
||||
// 显示该节点的详细信息
|
||||
const components = entity.components.map(c => c.constructor.name).join(', ');
|
||||
this.addLog(
|
||||
` - [${entity.name}] status=${btNode?.status}, nodeType=${btNode?.nodeType}, children=${entity.children.length}`,
|
||||
'info',
|
||||
nodeId
|
||||
);
|
||||
this.addLog(` 组件: ${components}`, 'info', nodeId);
|
||||
|
||||
// 显示子节点状态
|
||||
if (entity.children.length > 0) {
|
||||
entity.children.forEach((child: Entity, index: number) => {
|
||||
const childBtNode = child.getComponent(BehaviorTreeNodeComponent);
|
||||
const childActive = child.hasComponent(ActiveNode);
|
||||
this.addLog(
|
||||
` 子节点${index}: [${child.name}] status=${childBtNode?.status}, active=${childActive}`,
|
||||
'info',
|
||||
nodeId
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.stop();
|
||||
this.entityMap.clear();
|
||||
this.propertyBindingsMap.clear();
|
||||
this.rootEntity = null;
|
||||
|
||||
this.scene.destroyAllEntities();
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁
|
||||
*/
|
||||
destroy(): void {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user