Feature/ecs behavior tree (#188)

* feat(behavior-tree): 完全 ECS 化的行为树系统

* feat(editor-app): 添加行为树可视化编辑器

* chore: 移除 Cocos Creator 扩展目录

* feat(editor-app): 行为树编辑器功能增强

* fix(editor-app): 修复 TypeScript 类型错误

* feat(editor-app): 使用 FlexLayout 重构面板系统并优化资产浏览器

* feat(editor-app): 改进编辑器UI样式并修复行为树执行顺序

* feat(behavior-tree,editor-app): 添加装饰器系统并优化编辑器性能

* feat(behavior-tree,editor-app): 添加属性绑定系统

* feat(editor-app,behavior-tree): 优化编辑器UI并改进行为树功能

* feat(editor-app,behavior-tree): 添加全局黑板系统并增强资产浏览器功能

* feat(behavior-tree,editor-app): 添加运行时资产导出系统

* feat(behavior-tree,editor-app): 添加SubTree系统和资产选择器

* feat(behavior-tree,editor-app): 优化系统架构并改进编辑器文件管理

* fix(behavior-tree,editor-app): 修复SubTree节点错误显示空节点警告

* fix(editor-app): 修复局部黑板类型定义文件扩展名错误
This commit is contained in:
YHH
2025-10-27 09:29:11 +08:00
committed by GitHub
parent 0cd99209c4
commit 009f8af4e1
234 changed files with 21824 additions and 15295 deletions

View File

@@ -1,9 +1,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;

View File

@@ -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;
}
/**

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View 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"
}
}

View 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": "当前值"
}
}

View File

@@ -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>

View 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: 实现验证行为树的逻辑
}
}

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

View File

@@ -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 {

View File

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

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

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

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

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

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

View File

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

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

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

View File

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

View File

@@ -176,7 +176,7 @@
.startup-footer {
padding: 20px 40px;
border-top: 1px solid #2d2d30;
border-top: 1px solid #1e1e1e;
}
.startup-version {

View File

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

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

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

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