refactor(editor): 重构编辑器架构并增强行为树执行可视化
This commit is contained in:
@@ -63,6 +63,9 @@ export interface NodeRuntimeState {
|
|||||||
/** 当前执行的子节点索引(复合节点使用) */
|
/** 当前执行的子节点索引(复合节点使用) */
|
||||||
currentChildIndex: number;
|
currentChildIndex: number;
|
||||||
|
|
||||||
|
/** 执行顺序号(用于调试和可视化) */
|
||||||
|
executionOrder?: number;
|
||||||
|
|
||||||
/** 开始执行时间(某些节点需要) */
|
/** 开始执行时间(某些节点需要) */
|
||||||
startTime?: number;
|
startTime?: number;
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
|
|||||||
runtime.activeNodeIds.add(nodeData.id);
|
runtime.activeNodeIds.add(nodeData.id);
|
||||||
state.isAborted = false;
|
state.isAborted = false;
|
||||||
|
|
||||||
|
if (state.executionOrder === undefined) {
|
||||||
|
runtime.executionOrderCounter++;
|
||||||
|
state.executionOrder = runtime.executionOrderCounter;
|
||||||
|
}
|
||||||
|
|
||||||
const executor = this.executorRegistry.get(nodeData.implementationType);
|
const executor = this.executorRegistry.get(nodeData.implementationType);
|
||||||
if (!executor) {
|
if (!executor) {
|
||||||
this.logger.error(`未找到执行器: ${nodeData.implementationType}`);
|
this.logger.error(`未找到执行器: ${nodeData.implementationType}`);
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ export class BehaviorTreeRuntimeComponent extends Component {
|
|||||||
@IgnoreSerialization()
|
@IgnoreSerialization()
|
||||||
nodesToAbort: Set<string> = new Set();
|
nodesToAbort: Set<string> = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行顺序计数器(用于调试和可视化)
|
||||||
|
*/
|
||||||
|
@IgnoreSerialization()
|
||||||
|
executionOrderCounter: number = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取节点运行时状态
|
* 获取节点运行时状态
|
||||||
*/
|
*/
|
||||||
@@ -115,6 +121,7 @@ export class BehaviorTreeRuntimeComponent extends Component {
|
|||||||
resetAllStates(): void {
|
resetAllStates(): void {
|
||||||
this.nodeStates.clear();
|
this.nodeStates.clear();
|
||||||
this.activeNodeIds.clear();
|
this.activeNodeIds.clear();
|
||||||
|
this.executionOrderCounter = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -54,6 +54,35 @@ fn path_exists(path: String) -> Result<bool, String> {
|
|||||||
Ok(Path::new(&path).exists())
|
Ok(Path::new(&path).exists())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn rename_file_or_folder(old_path: String, new_path: String) -> Result<(), String> {
|
||||||
|
std::fs::rename(&old_path, &new_path)
|
||||||
|
.map_err(|e| format!("Failed to rename: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_file(path: String) -> Result<(), String> {
|
||||||
|
std::fs::remove_file(&path)
|
||||||
|
.map_err(|e| format!("Failed to delete file: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_folder(path: String) -> Result<(), String> {
|
||||||
|
std::fs::remove_dir_all(&path)
|
||||||
|
.map_err(|e| format!("Failed to delete folder: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn create_file(path: String) -> Result<(), String> {
|
||||||
|
use std::fs::File;
|
||||||
|
File::create(&path)
|
||||||
|
.map_err(|e| format!("Failed to create file: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn open_project_dialog(app: AppHandle) -> Result<Option<String>, String> {
|
async fn open_project_dialog(app: AppHandle) -> Result<Option<String>, String> {
|
||||||
use tauri_plugin_dialog::DialogExt;
|
use tauri_plugin_dialog::DialogExt;
|
||||||
@@ -572,6 +601,10 @@ fn main() {
|
|||||||
create_directory,
|
create_directory,
|
||||||
write_file_content,
|
write_file_content,
|
||||||
path_exists,
|
path_exists,
|
||||||
|
rename_file_or_folder,
|
||||||
|
delete_file,
|
||||||
|
delete_folder,
|
||||||
|
create_file,
|
||||||
open_project_dialog,
|
open_project_dialog,
|
||||||
save_scene_dialog,
|
save_scene_dialog,
|
||||||
open_scene_dialog,
|
open_scene_dialog,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Core, Scene } from '@esengine/ecs-framework';
|
import { Core, Scene } from '@esengine/ecs-framework';
|
||||||
import * as ECSFramework 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 { EditorPluginManager, UIRegistry, MessageHub, SerializerRegistry, EntityStoreService, ComponentRegistry, LocaleService, ProjectService, ComponentDiscoveryService, PropertyMetadataService, LogService, SettingsRegistry, SceneManagerService, FileActionRegistry, PanelDescriptor } from '@esengine/editor-core';
|
||||||
import { GlobalBlackboardService } from '@esengine/behavior-tree';
|
import { GlobalBlackboardService } from '@esengine/behavior-tree';
|
||||||
import { SceneInspectorPlugin } from './plugins/SceneInspectorPlugin';
|
import { SceneInspectorPlugin } from './plugins/SceneInspectorPlugin';
|
||||||
import { ProfilerPlugin } from './plugins/ProfilerPlugin';
|
import { ProfilerPlugin } from './plugins/ProfilerPlugin';
|
||||||
@@ -9,7 +9,7 @@ import { EditorAppearancePlugin } from './plugins/EditorAppearancePlugin';
|
|||||||
import { BehaviorTreePlugin } from './plugins/BehaviorTreePlugin';
|
import { BehaviorTreePlugin } from './plugins/BehaviorTreePlugin';
|
||||||
import { StartupPage } from './components/StartupPage';
|
import { StartupPage } from './components/StartupPage';
|
||||||
import { SceneHierarchy } from './components/SceneHierarchy';
|
import { SceneHierarchy } from './components/SceneHierarchy';
|
||||||
import { EntityInspector } from './components/EntityInspector';
|
import { Inspector } from './components/Inspector';
|
||||||
import { AssetBrowser } from './components/AssetBrowser';
|
import { AssetBrowser } from './components/AssetBrowser';
|
||||||
import { ConsolePanel } from './components/ConsolePanel';
|
import { ConsolePanel } from './components/ConsolePanel';
|
||||||
import { PluginManagerWindow } from './components/PluginManagerWindow';
|
import { PluginManagerWindow } from './components/PluginManagerWindow';
|
||||||
@@ -19,7 +19,6 @@ import { SettingsWindow } from './components/SettingsWindow';
|
|||||||
import { AboutDialog } from './components/AboutDialog';
|
import { AboutDialog } from './components/AboutDialog';
|
||||||
import { ErrorDialog } from './components/ErrorDialog';
|
import { ErrorDialog } from './components/ErrorDialog';
|
||||||
import { ConfirmDialog } from './components/ConfirmDialog';
|
import { ConfirmDialog } from './components/ConfirmDialog';
|
||||||
import { BehaviorTreeWindow } from './components/BehaviorTreeWindow';
|
|
||||||
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
|
||||||
import { ToastProvider } from './components/Toast';
|
import { ToastProvider } from './components/Toast';
|
||||||
import { MenuBar } from './components/MenuBar';
|
import { MenuBar } from './components/MenuBar';
|
||||||
@@ -67,8 +66,6 @@ function App() {
|
|||||||
const [showPortManager, setShowPortManager] = useState(false);
|
const [showPortManager, setShowPortManager] = useState(false);
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
const [showBehaviorTreeEditor, setShowBehaviorTreeEditor] = useState(false);
|
|
||||||
const [behaviorTreeFilePath, setBehaviorTreeFilePath] = useState<string | null>(null);
|
|
||||||
const [showPluginGenerator, setShowPluginGenerator] = useState(false);
|
const [showPluginGenerator, setShowPluginGenerator] = useState(false);
|
||||||
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
|
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
|
||||||
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
|
||||||
@@ -81,6 +78,7 @@ function App() {
|
|||||||
cancelText: string;
|
cancelText: string;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [activeDynamicPanels, setActiveDynamicPanels] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 禁用默认右键菜单
|
// 禁用默认右键菜单
|
||||||
@@ -167,6 +165,7 @@ function App() {
|
|||||||
const logService = new LogService();
|
const logService = new LogService();
|
||||||
const settingsRegistry = new SettingsRegistry();
|
const settingsRegistry = new SettingsRegistry();
|
||||||
const sceneManagerService = new SceneManagerService(messageHub, fileAPI, projectService);
|
const sceneManagerService = new SceneManagerService(messageHub, fileAPI, projectService);
|
||||||
|
const fileActionRegistry = new FileActionRegistry();
|
||||||
|
|
||||||
// 监听远程日志事件
|
// 监听远程日志事件
|
||||||
window.addEventListener('profiler:remote-log', ((event: CustomEvent) => {
|
window.addEventListener('profiler:remote-log', ((event: CustomEvent) => {
|
||||||
@@ -185,6 +184,7 @@ function App() {
|
|||||||
Core.services.registerInstance(LogService, logService);
|
Core.services.registerInstance(LogService, logService);
|
||||||
Core.services.registerInstance(SettingsRegistry, settingsRegistry);
|
Core.services.registerInstance(SettingsRegistry, settingsRegistry);
|
||||||
Core.services.registerInstance(SceneManagerService, sceneManagerService);
|
Core.services.registerInstance(SceneManagerService, sceneManagerService);
|
||||||
|
Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
|
||||||
|
|
||||||
const pluginMgr = new EditorPluginManager();
|
const pluginMgr = new EditorPluginManager();
|
||||||
pluginMgr.initialize(coreInstance, Core.services);
|
pluginMgr.initialize(coreInstance, Core.services);
|
||||||
@@ -196,12 +196,14 @@ function App() {
|
|||||||
await pluginMgr.installEditor(new BehaviorTreePlugin());
|
await pluginMgr.installEditor(new BehaviorTreePlugin());
|
||||||
|
|
||||||
messageHub.subscribe('ui:openWindow', (data: any) => {
|
messageHub.subscribe('ui:openWindow', (data: any) => {
|
||||||
if (data.windowId === 'profiler') {
|
console.log('[App] Received ui:openWindow:', data);
|
||||||
|
const { windowId, ...params } = data;
|
||||||
|
|
||||||
|
// 内置窗口处理
|
||||||
|
if (windowId === 'profiler') {
|
||||||
setShowProfiler(true);
|
setShowProfiler(true);
|
||||||
} else if (data.windowId === 'pluginManager') {
|
} else if (windowId === 'pluginManager') {
|
||||||
setShowPluginManager(true);
|
setShowPluginManager(true);
|
||||||
} else if (data.windowId === 'behavior-tree-editor') {
|
|
||||||
setShowBehaviorTreeEditor(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,6 +230,23 @@ function App() {
|
|||||||
initializeEditor();
|
initializeEditor();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!messageHub) return;
|
||||||
|
|
||||||
|
const unsubscribe = messageHub.subscribe('dynamic-panel:open', (data: any) => {
|
||||||
|
const { panelId } = data;
|
||||||
|
console.log('[App] Opening dynamic panel:', panelId);
|
||||||
|
setActiveDynamicPanels(prev => {
|
||||||
|
if (prev.includes(panelId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [...prev, panelId];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe?.();
|
||||||
|
}, [messageHub]);
|
||||||
|
|
||||||
const handleOpenRecentProject = async (projectPath: string) => {
|
const handleOpenRecentProject = async (projectPath: string) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -442,10 +461,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [sceneManager, locale]);
|
}, [sceneManager, locale]);
|
||||||
|
|
||||||
const handleOpenBehaviorTree = useCallback((btreePath: string) => {
|
|
||||||
setBehaviorTreeFilePath(btreePath);
|
|
||||||
setShowBehaviorTreeEditor(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSaveScene = async () => {
|
const handleSaveScene = async () => {
|
||||||
if (!sceneManager) {
|
if (!sceneManager) {
|
||||||
@@ -544,7 +559,7 @@ function App() {
|
|||||||
{
|
{
|
||||||
id: 'inspector',
|
id: 'inspector',
|
||||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||||
content: <EntityInspector entityStore={entityStore} messageHub={messageHub} />,
|
content: <Inspector entityStore={entityStore} messageHub={messageHub} projectPath={currentProjectPath} />,
|
||||||
closable: false
|
closable: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -565,13 +580,13 @@ function App() {
|
|||||||
{
|
{
|
||||||
id: 'inspector',
|
id: 'inspector',
|
||||||
title: locale === 'zh' ? '检视器' : 'Inspector',
|
title: locale === 'zh' ? '检视器' : 'Inspector',
|
||||||
content: <EntityInspector entityStore={entityStore} messageHub={messageHub} />,
|
content: <Inspector entityStore={entityStore} messageHub={messageHub} projectPath={currentProjectPath} />,
|
||||||
closable: false
|
closable: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'assets',
|
id: 'assets',
|
||||||
title: locale === 'zh' ? '资产' : 'Assets',
|
title: locale === 'zh' ? '资产' : 'Assets',
|
||||||
content: <AssetBrowser projectPath={currentProjectPath} locale={locale} onOpenScene={handleOpenSceneByPath} onOpenBehaviorTree={handleOpenBehaviorTree} />,
|
content: <AssetBrowser projectPath={currentProjectPath} locale={locale} onOpenScene={handleOpenSceneByPath} />,
|
||||||
closable: false
|
closable: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -592,6 +607,10 @@ function App() {
|
|||||||
if (!panelDesc.component) {
|
if (!panelDesc.component) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// 过滤掉动态面板
|
||||||
|
if (panelDesc.isDynamic) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return enabledPlugins.some((pluginName) => {
|
return enabledPlugins.some((pluginName) => {
|
||||||
const plugin = pluginManager.getEditorPlugin(pluginName);
|
const plugin = pluginManager.getEditorPlugin(pluginName);
|
||||||
if (plugin && plugin.registerPanels) {
|
if (plugin && plugin.registerPanels) {
|
||||||
@@ -606,15 +625,33 @@ function App() {
|
|||||||
return {
|
return {
|
||||||
id: panelDesc.id,
|
id: panelDesc.id,
|
||||||
title: (panelDesc as any).titleZh && locale === 'zh' ? (panelDesc as any).titleZh : panelDesc.title,
|
title: (panelDesc as any).titleZh && locale === 'zh' ? (panelDesc as any).titleZh : panelDesc.title,
|
||||||
content: <Component />,
|
content: <Component projectPath={currentProjectPath} />,
|
||||||
|
closable: panelDesc.closable ?? true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加激活的动态面板
|
||||||
|
const dynamicPanels: FlexDockPanel[] = activeDynamicPanels
|
||||||
|
.filter(panelId => {
|
||||||
|
const panelDesc = uiRegistry.getPanel(panelId);
|
||||||
|
return panelDesc && panelDesc.component;
|
||||||
|
})
|
||||||
|
.map(panelId => {
|
||||||
|
const panelDesc = uiRegistry.getPanel(panelId)!;
|
||||||
|
const Component = panelDesc.component;
|
||||||
|
return {
|
||||||
|
id: panelDesc.id,
|
||||||
|
title: (panelDesc as any).titleZh && locale === 'zh' ? (panelDesc as any).titleZh : panelDesc.title,
|
||||||
|
content: <Component projectPath={currentProjectPath} />,
|
||||||
closable: panelDesc.closable ?? true
|
closable: panelDesc.closable ?? true
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[App] Loading plugin panels:', pluginPanels);
|
console.log('[App] Loading plugin panels:', pluginPanels);
|
||||||
setPanels([...corePanels, ...pluginPanels]);
|
console.log('[App] Loading dynamic panels:', dynamicPanels);
|
||||||
|
setPanels([...corePanels, ...pluginPanels, ...dynamicPanels]);
|
||||||
}
|
}
|
||||||
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, isProfilerMode, handleOpenSceneByPath, handleOpenBehaviorTree]);
|
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, isProfilerMode, handleOpenSceneByPath, activeDynamicPanels]);
|
||||||
|
|
||||||
|
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
@@ -701,7 +738,14 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="editor-content">
|
<div className="editor-content">
|
||||||
<FlexLayoutDockContainer panels={panels} />
|
<FlexLayoutDockContainer
|
||||||
|
panels={panels}
|
||||||
|
onPanelClose={(panelId) => {
|
||||||
|
console.log('[App] Panel closed:', panelId);
|
||||||
|
// 从激活的动态面板列表中移除
|
||||||
|
setActiveDynamicPanels(prev => prev.filter(id => id !== panelId));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="editor-footer">
|
<div className="editor-footer">
|
||||||
@@ -748,18 +792,6 @@ function App() {
|
|||||||
<AboutDialog onClose={() => setShowAbout(false)} locale={locale} />
|
<AboutDialog onClose={() => setShowAbout(false)} locale={locale} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showBehaviorTreeEditor && (
|
|
||||||
<BehaviorTreeWindow
|
|
||||||
isOpen={showBehaviorTreeEditor}
|
|
||||||
onClose={() => {
|
|
||||||
setShowBehaviorTreeEditor(false);
|
|
||||||
setBehaviorTreeFilePath(null);
|
|
||||||
}}
|
|
||||||
filePath={behaviorTreeFilePath}
|
|
||||||
projectPath={currentProjectPath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showPluginGenerator && (
|
{showPluginGenerator && (
|
||||||
<PluginGeneratorWindow
|
<PluginGeneratorWindow
|
||||||
onClose={() => setShowPluginGenerator(false)}
|
onClose={() => setShowPluginGenerator(false)}
|
||||||
|
|||||||
@@ -146,6 +146,39 @@ export class TauriAPI {
|
|||||||
static async scanBehaviorTrees(projectPath: string): Promise<string[]> {
|
static async scanBehaviorTrees(projectPath: string): Promise<string[]> {
|
||||||
return await invoke<string[]>('scan_behavior_trees', { projectPath });
|
return await invoke<string[]>('scan_behavior_trees', { projectPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名文件或文件夹
|
||||||
|
* @param oldPath 原路径
|
||||||
|
* @param newPath 新路径
|
||||||
|
*/
|
||||||
|
static async renameFileOrFolder(oldPath: string, newPath: string): Promise<void> {
|
||||||
|
return await invoke<void>('rename_file_or_folder', { oldPath, newPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件
|
||||||
|
* @param path 文件路径
|
||||||
|
*/
|
||||||
|
static async deleteFile(path: string): Promise<void> {
|
||||||
|
return await invoke<void>('delete_file', { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件夹
|
||||||
|
* @param path 文件夹路径
|
||||||
|
*/
|
||||||
|
static async deleteFolder(path: string): Promise<void> {
|
||||||
|
return await invoke<void>('delete_folder', { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建文件
|
||||||
|
* @param path 文件路径
|
||||||
|
*/
|
||||||
|
static async createFile(path: string): Promise<void> {
|
||||||
|
return await invoke<void>('create_file', { path });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DirectoryEntry {
|
export interface DirectoryEntry {
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ export function useContextMenu() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCanvasContextMenu = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setContextMenu({
|
||||||
|
visible: true,
|
||||||
|
position: { x: e.clientX, y: e.clientY },
|
||||||
|
nodeId: null
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const closeContextMenu = () => {
|
const closeContextMenu = () => {
|
||||||
setContextMenu({ ...contextMenu, visible: false });
|
setContextMenu({ ...contextMenu, visible: false });
|
||||||
};
|
};
|
||||||
@@ -38,6 +49,7 @@ export function useContextMenu() {
|
|||||||
contextMenu,
|
contextMenu,
|
||||||
setContextMenu,
|
setContextMenu,
|
||||||
handleNodeContextMenu,
|
handleNodeContextMenu,
|
||||||
|
handleCanvasContextMenu,
|
||||||
closeContextMenu
|
closeContextMenu
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,11 +131,6 @@ export function useQuickCreateMenu(params: UseQuickCreateMenuParams) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建模式:需要连接
|
|
||||||
if (!connectingFrom) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) {
|
if (!rect) {
|
||||||
return;
|
return;
|
||||||
@@ -150,6 +145,8 @@ export function useQuickCreateMenu(params: UseQuickCreateMenuParams) {
|
|||||||
template.defaultConfig
|
template.defaultConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 如果有连接源,创建连接
|
||||||
|
if (connectingFrom) {
|
||||||
const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom);
|
const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom);
|
||||||
if (fromNode) {
|
if (fromNode) {
|
||||||
if (connectingFromProperty) {
|
if (connectingFromProperty) {
|
||||||
@@ -166,6 +163,7 @@ export function useQuickCreateMenu(params: UseQuickCreateMenuParams) {
|
|||||||
connectionOperations.addConnection(connectingFrom, newNode.id, 'node');
|
connectionOperations.addConnection(connectingFrom, newNode.id, 'node');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
closeQuickCreateMenu();
|
closeQuickCreateMenu();
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { BehaviorTreeExecutor, ExecutionStatus, ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
import { BehaviorTreeExecutor, ExecutionStatus, ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
||||||
import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore';
|
import { BehaviorTreeNode, Connection, NodeExecutionStatus } from '../../stores/behaviorTreeStore';
|
||||||
import { BlackboardValue } from '../../domain/models/Blackboard';
|
import { BlackboardValue } from '../../domain/models/Blackboard';
|
||||||
import { DOMCache } from '../../presentation/utils/DOMCache';
|
import { DOMCache } from '../../presentation/utils/DOMCache';
|
||||||
import { EditorEventBus, EditorEvent } from '../../infrastructure/events/EditorEventBus';
|
import { EditorEventBus, EditorEvent } from '../../infrastructure/events/EditorEventBus';
|
||||||
import { ExecutionHooksManager } from '../interfaces/IExecutionHooks';
|
import { ExecutionHooksManager } from '../interfaces/IExecutionHooks';
|
||||||
|
|
||||||
export type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
|
export type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
|
||||||
type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure';
|
|
||||||
type BlackboardVariables = Record<string, BlackboardValue>;
|
type BlackboardVariables = Record<string, BlackboardValue>;
|
||||||
|
|
||||||
interface ExecutionControllerConfig {
|
interface ExecutionControllerConfig {
|
||||||
@@ -15,6 +14,7 @@ interface ExecutionControllerConfig {
|
|||||||
onLogsUpdate: (logs: ExecutionLog[]) => void;
|
onLogsUpdate: (logs: ExecutionLog[]) => void;
|
||||||
onBlackboardUpdate: (variables: BlackboardVariables) => void;
|
onBlackboardUpdate: (variables: BlackboardVariables) => void;
|
||||||
onTickCountUpdate: (count: number) => void;
|
onTickCountUpdate: (count: number) => void;
|
||||||
|
onExecutionStatusUpdate: (statuses: Map<string, NodeExecutionStatus>, orders: Map<string, number>) => void;
|
||||||
eventBus?: EditorEventBus;
|
eventBus?: EditorEventBus;
|
||||||
hooksManager?: ExecutionHooksManager;
|
hooksManager?: ExecutionHooksManager;
|
||||||
}
|
}
|
||||||
@@ -36,6 +36,12 @@ export class ExecutionController {
|
|||||||
private currentConnections: Connection[] = [];
|
private currentConnections: Connection[] = [];
|
||||||
private currentBlackboard: BlackboardVariables = {};
|
private currentBlackboard: BlackboardVariables = {};
|
||||||
|
|
||||||
|
private stepByStepMode: boolean = true;
|
||||||
|
private pendingStatusUpdates: ExecutionStatus[] = [];
|
||||||
|
private currentlyDisplayedIndex: number = 0;
|
||||||
|
private lastStepTime: number = 0;
|
||||||
|
private stepInterval: number = 200;
|
||||||
|
|
||||||
constructor(config: ExecutionControllerConfig) {
|
constructor(config: ExecutionControllerConfig) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.executor = new BehaviorTreeExecutor();
|
this.executor = new BehaviorTreeExecutor();
|
||||||
@@ -57,6 +63,7 @@ export class ExecutionController {
|
|||||||
|
|
||||||
setSpeed(speed: number): void {
|
setSpeed(speed: number): void {
|
||||||
this.speed = speed;
|
this.speed = speed;
|
||||||
|
this.lastTickTime = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async play(
|
async play(
|
||||||
@@ -156,23 +163,14 @@ export class ExecutionController {
|
|||||||
this.mode = 'idle';
|
this.mode = 'idle';
|
||||||
this.tickCount = 0;
|
this.tickCount = 0;
|
||||||
this.lastTickTime = 0;
|
this.lastTickTime = 0;
|
||||||
|
this.lastStepTime = 0;
|
||||||
|
this.pendingStatusUpdates = [];
|
||||||
|
this.currentlyDisplayedIndex = 0;
|
||||||
|
|
||||||
this.domCache.clearAllStatusTimers();
|
this.domCache.clearAllStatusTimers();
|
||||||
this.domCache.clearStatusCache();
|
this.domCache.clearStatusCache();
|
||||||
|
|
||||||
this.domCache.forEachNode((node) => {
|
this.config.onExecutionStatusUpdate(new Map(), new Map());
|
||||||
node.classList.remove('running', 'success', 'failure', 'executed');
|
|
||||||
});
|
|
||||||
|
|
||||||
this.domCache.forEachConnection((path) => {
|
|
||||||
const connectionType = path.getAttribute('data-connection-type');
|
|
||||||
if (connectionType === 'property') {
|
|
||||||
path.setAttribute('stroke', '#9c27b0');
|
|
||||||
} else {
|
|
||||||
path.setAttribute('stroke', '#0e639c');
|
|
||||||
}
|
|
||||||
path.setAttribute('stroke-width', '2');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.animationFrameId !== null) {
|
if (this.animationFrameId !== null) {
|
||||||
cancelAnimationFrame(this.animationFrameId);
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
@@ -217,6 +215,24 @@ export class ExecutionController {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateNodes(nodes: BehaviorTreeNode[]): void {
|
||||||
|
if (this.mode === 'idle' || !this.executor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentNodes = nodes;
|
||||||
|
|
||||||
|
this.executor.buildTree(
|
||||||
|
nodes,
|
||||||
|
this.config.rootNodeId,
|
||||||
|
this.currentBlackboard,
|
||||||
|
this.currentConnections,
|
||||||
|
this.handleExecutionStatusUpdate.bind(this)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.executor.start();
|
||||||
|
}
|
||||||
|
|
||||||
clearDOMCache(): void {
|
clearDOMCache(): void {
|
||||||
this.domCache.clearAll();
|
this.domCache.clearAll();
|
||||||
}
|
}
|
||||||
@@ -239,21 +255,96 @@ export class ExecutionController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.stepByStepMode) {
|
||||||
|
this.handleStepByStepExecution(currentTime);
|
||||||
|
} else {
|
||||||
|
this.handleNormalExecution(currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleNormalExecution(currentTime: number): void {
|
||||||
const baseTickInterval = 16.67;
|
const baseTickInterval = 16.67;
|
||||||
const tickInterval = baseTickInterval / this.speed;
|
const scaledTickInterval = baseTickInterval / this.speed;
|
||||||
|
|
||||||
if (this.lastTickTime === 0 || (currentTime - this.lastTickTime) >= tickInterval) {
|
if (this.lastTickTime === 0) {
|
||||||
const deltaTime = 0.016;
|
this.lastTickTime = currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
this.executor.tick(deltaTime);
|
const elapsed = currentTime - this.lastTickTime;
|
||||||
|
|
||||||
this.tickCount = this.executor.getTickCount();
|
if (elapsed >= scaledTickInterval) {
|
||||||
|
const deltaTime = baseTickInterval / 1000;
|
||||||
|
|
||||||
|
this.executor!.tick(deltaTime);
|
||||||
|
|
||||||
|
this.tickCount = this.executor!.getTickCount();
|
||||||
this.config.onTickCountUpdate(this.tickCount);
|
this.config.onTickCountUpdate(this.tickCount);
|
||||||
|
|
||||||
this.lastTickTime = currentTime;
|
this.lastTickTime = currentTime;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
|
private handleStepByStepExecution(currentTime: number): void {
|
||||||
|
if (this.lastStepTime === 0) {
|
||||||
|
this.lastStepTime = currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepElapsed = currentTime - this.lastStepTime;
|
||||||
|
const actualStepInterval = this.stepInterval / this.speed;
|
||||||
|
|
||||||
|
if (stepElapsed >= actualStepInterval) {
|
||||||
|
if (this.currentlyDisplayedIndex < this.pendingStatusUpdates.length) {
|
||||||
|
this.displayNextNode();
|
||||||
|
this.lastStepTime = currentTime;
|
||||||
|
} else {
|
||||||
|
if (this.lastTickTime === 0) {
|
||||||
|
this.lastTickTime = currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickElapsed = currentTime - this.lastTickTime;
|
||||||
|
const baseTickInterval = 16.67;
|
||||||
|
const scaledTickInterval = baseTickInterval / this.speed;
|
||||||
|
|
||||||
|
if (tickElapsed >= scaledTickInterval) {
|
||||||
|
const deltaTime = baseTickInterval / 1000;
|
||||||
|
this.executor!.tick(deltaTime);
|
||||||
|
this.tickCount = this.executor!.getTickCount();
|
||||||
|
this.config.onTickCountUpdate(this.tickCount);
|
||||||
|
this.lastTickTime = currentTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private displayNextNode(): void {
|
||||||
|
if (this.currentlyDisplayedIndex >= this.pendingStatusUpdates.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusesToDisplay = this.pendingStatusUpdates.slice(0, this.currentlyDisplayedIndex + 1);
|
||||||
|
const currentNode = this.pendingStatusUpdates[this.currentlyDisplayedIndex];
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMap = new Map<string, NodeExecutionStatus>();
|
||||||
|
const orderMap = new Map<string, number>();
|
||||||
|
|
||||||
|
statusesToDisplay.forEach((s) => {
|
||||||
|
statusMap.set(s.nodeId, s.status);
|
||||||
|
if (s.executionOrder !== undefined) {
|
||||||
|
orderMap.set(s.nodeId, s.executionOrder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodeName = this.currentNodes.find(n => n.id === currentNode.nodeId)?.template.displayName || 'Unknown';
|
||||||
|
console.log(`[StepByStep] Displaying ${this.currentlyDisplayedIndex + 1}/${this.pendingStatusUpdates.length} | ${nodeName} | Order: ${currentNode.executionOrder} | ID: ${currentNode.nodeId}`);
|
||||||
|
this.config.onExecutionStatusUpdate(statusMap, orderMap);
|
||||||
|
|
||||||
|
this.currentlyDisplayedIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleExecutionStatusUpdate(
|
private handleExecutionStatusUpdate(
|
||||||
@@ -267,52 +358,49 @@ export class ExecutionController {
|
|||||||
this.config.onBlackboardUpdate(runtimeBlackboardVars);
|
this.config.onBlackboardUpdate(runtimeBlackboardVars);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusMap: Record<string, NodeExecutionStatus> = {};
|
if (this.stepByStepMode) {
|
||||||
|
const statusesWithOrder = statuses.filter(s => s.executionOrder !== undefined);
|
||||||
|
|
||||||
|
if (statusesWithOrder.length > 0) {
|
||||||
|
const minOrder = Math.min(...statusesWithOrder.map(s => s.executionOrder!));
|
||||||
|
|
||||||
|
if (minOrder === 1 || this.pendingStatusUpdates.length === 0) {
|
||||||
|
this.pendingStatusUpdates = statusesWithOrder.sort((a, b) =>
|
||||||
|
(a.executionOrder || 0) - (b.executionOrder || 0)
|
||||||
|
);
|
||||||
|
this.currentlyDisplayedIndex = 0;
|
||||||
|
this.lastStepTime = 0;
|
||||||
|
} else {
|
||||||
|
const maxExistingOrder = this.pendingStatusUpdates.length > 0
|
||||||
|
? Math.max(...this.pendingStatusUpdates.map(s => s.executionOrder || 0))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const newStatuses = statusesWithOrder.filter(s =>
|
||||||
|
(s.executionOrder || 0) > maxExistingOrder
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newStatuses.length > 0) {
|
||||||
|
console.log(`[StepByStep] Appending ${newStatuses.length} new nodes, orders:`, newStatuses.map(s => s.executionOrder));
|
||||||
|
this.pendingStatusUpdates = [
|
||||||
|
...this.pendingStatusUpdates,
|
||||||
|
...newStatuses
|
||||||
|
].sort((a, b) => (a.executionOrder || 0) - (b.executionOrder || 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const statusMap = new Map<string, NodeExecutionStatus>();
|
||||||
|
const orderMap = new Map<string, number>();
|
||||||
|
|
||||||
statuses.forEach((s) => {
|
statuses.forEach((s) => {
|
||||||
statusMap[s.nodeId] = s.status;
|
statusMap.set(s.nodeId, s.status);
|
||||||
|
if (s.executionOrder !== undefined) {
|
||||||
if (!this.domCache.hasStatusChanged(s.nodeId, s.status)) {
|
orderMap.set(s.nodeId, s.executionOrder);
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.domCache.setLastStatus(s.nodeId, s.status);
|
|
||||||
|
|
||||||
const nodeElement = this.domCache.getNode(s.nodeId);
|
|
||||||
if (!nodeElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.domCache.removeNodeClasses(s.nodeId, 'running', 'success', 'failure', 'executed');
|
|
||||||
|
|
||||||
if (s.status === 'running') {
|
|
||||||
this.domCache.addNodeClasses(s.nodeId, 'running');
|
|
||||||
} else if (s.status === 'success') {
|
|
||||||
this.domCache.addNodeClasses(s.nodeId, 'success');
|
|
||||||
|
|
||||||
this.domCache.clearStatusTimer(s.nodeId);
|
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
this.domCache.removeNodeClasses(s.nodeId, 'success');
|
|
||||||
this.domCache.addNodeClasses(s.nodeId, 'executed');
|
|
||||||
this.domCache.clearStatusTimer(s.nodeId);
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
this.domCache.setStatusTimer(s.nodeId, timer);
|
|
||||||
} else if (s.status === 'failure') {
|
|
||||||
this.domCache.addNodeClasses(s.nodeId, 'failure');
|
|
||||||
|
|
||||||
this.domCache.clearStatusTimer(s.nodeId);
|
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
this.domCache.removeNodeClasses(s.nodeId, 'failure');
|
|
||||||
this.domCache.clearStatusTimer(s.nodeId);
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
this.domCache.setStatusTimer(s.nodeId, timer);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.updateConnectionStyles(statusMap);
|
this.config.onExecutionStatusUpdate(statusMap, orderMap);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateConnectionStyles(
|
private updateConnectionStyles(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3 } from 'lucide-react';
|
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3, LayoutGrid, List } from 'lucide-react';
|
||||||
import { Core } from '@esengine/ecs-framework';
|
import { Core } from '@esengine/ecs-framework';
|
||||||
import { MessageHub } from '@esengine/editor-core';
|
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
|
||||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||||
import { FileTree } from './FileTree';
|
import { FileTree } from './FileTree';
|
||||||
import { ResizablePanel } from './ResizablePanel';
|
import { ResizablePanel } from './ResizablePanel';
|
||||||
@@ -13,21 +13,30 @@ interface AssetItem {
|
|||||||
path: string;
|
path: string;
|
||||||
type: 'file' | 'folder';
|
type: 'file' | 'folder';
|
||||||
extension?: string;
|
extension?: string;
|
||||||
|
size?: number;
|
||||||
|
modified?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssetBrowserProps {
|
interface AssetBrowserProps {
|
||||||
projectPath: string | null;
|
projectPath: string | null;
|
||||||
locale: string;
|
locale: string;
|
||||||
onOpenScene?: (scenePath: string) => void;
|
onOpenScene?: (scenePath: string) => void;
|
||||||
onOpenBehaviorTree?: (btreePath: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorTree }: AssetBrowserProps) {
|
export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserProps) {
|
||||||
|
const messageHub = Core.services.resolve(MessageHub);
|
||||||
|
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
|
||||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||||
const [assets, setAssets] = useState<AssetItem[]>([]);
|
const [assets, setAssets] = useState<AssetItem[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<AssetItem[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showDetailView, setShowDetailView] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('asset-browser-detail-view');
|
||||||
|
return saved !== null ? saved === 'true' : false;
|
||||||
|
});
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
asset: AssetItem;
|
asset: AssetItem;
|
||||||
@@ -105,7 +114,9 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
|
|||||||
name: entry.name,
|
name: entry.name,
|
||||||
path: entry.path,
|
path: entry.path,
|
||||||
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
||||||
extension
|
extension,
|
||||||
|
size: entry.size,
|
||||||
|
modified: entry.modified
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,6 +132,73 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const searchProjectRecursively = async (rootPath: string, query: string): Promise<AssetItem[]> => {
|
||||||
|
const results: AssetItem[] = [];
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
const searchDirectory = async (dirPath: string) => {
|
||||||
|
try {
|
||||||
|
const entries = await TauriAPI.listDirectory(dirPath);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name.startsWith('.')) continue;
|
||||||
|
|
||||||
|
if (entry.name.toLowerCase().includes(lowerQuery)) {
|
||||||
|
const extension = entry.is_dir ? undefined :
|
||||||
|
(entry.name.includes('.') ? entry.name.split('.').pop() : undefined);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: entry.path,
|
||||||
|
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
||||||
|
extension,
|
||||||
|
size: entry.size,
|
||||||
|
modified: entry.modified
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.is_dir) {
|
||||||
|
await searchDirectory(entry.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to search directory ${dirPath}:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await searchDirectory(rootPath);
|
||||||
|
return results.sort((a, b) => {
|
||||||
|
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||||
|
return a.type === 'folder' ? -1 : 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const performSearch = async () => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setSearchResults([]);
|
||||||
|
setIsSearching(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectPath) return;
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const results = await searchProjectRecursively(projectPath, searchQuery);
|
||||||
|
setSearchResults(results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(performSearch, 300);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [searchQuery, projectPath]);
|
||||||
|
|
||||||
const handleFolderSelect = (path: string) => {
|
const handleFolderSelect = (path: string) => {
|
||||||
setCurrentPath(path);
|
setCurrentPath(path);
|
||||||
loadAssets(path);
|
loadAssets(path);
|
||||||
@@ -128,6 +206,17 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
|
|||||||
|
|
||||||
const handleAssetClick = (asset: AssetItem) => {
|
const handleAssetClick = (asset: AssetItem) => {
|
||||||
setSelectedPath(asset.path);
|
setSelectedPath(asset.path);
|
||||||
|
|
||||||
|
messageHub?.publish('asset-file:selected', {
|
||||||
|
fileInfo: {
|
||||||
|
name: asset.name,
|
||||||
|
path: asset.path,
|
||||||
|
extension: asset.extension,
|
||||||
|
size: asset.size,
|
||||||
|
modified: asset.modified,
|
||||||
|
isDirectory: asset.type === 'folder'
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssetDoubleClick = async (asset: AssetItem) => {
|
const handleAssetDoubleClick = async (asset: AssetItem) => {
|
||||||
@@ -137,17 +226,27 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
|
|||||||
} else if (asset.type === 'file') {
|
} else if (asset.type === 'file') {
|
||||||
if (asset.extension === 'ecs' && onOpenScene) {
|
if (asset.extension === 'ecs' && onOpenScene) {
|
||||||
onOpenScene(asset.path);
|
onOpenScene(asset.path);
|
||||||
} else if (asset.extension === 'btree' && onOpenBehaviorTree) {
|
return;
|
||||||
onOpenBehaviorTree(asset.path);
|
}
|
||||||
|
|
||||||
|
if (fileActionRegistry) {
|
||||||
|
console.log('[AssetBrowser] Handling double click for:', asset.path);
|
||||||
|
console.log('[AssetBrowser] Extension:', asset.extension);
|
||||||
|
const handled = await fileActionRegistry.handleDoubleClick(asset.path);
|
||||||
|
console.log('[AssetBrowser] Handled by plugin:', handled);
|
||||||
|
if (handled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 其他文件使用系统默认程序打开
|
console.log('[AssetBrowser] FileActionRegistry not available');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TauriAPI.openFileWithSystemApp(asset.path);
|
await TauriAPI.openFileWithSystemApp(asset.path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to open file:', error);
|
console.error('Failed to open file:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent, asset: AssetItem) => {
|
const handleContextMenu = (e: React.MouseEvent, asset: AssetItem) => {
|
||||||
@@ -168,6 +267,27 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
|
|||||||
icon: <File size={16} />,
|
icon: <File size={16} />,
|
||||||
onClick: () => handleAssetDoubleClick(asset)
|
onClick: () => handleAssetDoubleClick(asset)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (fileActionRegistry) {
|
||||||
|
const handlers = fileActionRegistry.getHandlersForFile(asset.path);
|
||||||
|
for (const handler of handlers) {
|
||||||
|
if (handler.getContextMenuItems) {
|
||||||
|
const parentPath = asset.path.substring(0, asset.path.lastIndexOf('/'));
|
||||||
|
const pluginItems = handler.getContextMenuItems(asset.path, parentPath);
|
||||||
|
for (const pluginItem of pluginItems) {
|
||||||
|
items.push({
|
||||||
|
label: pluginItem.label,
|
||||||
|
icon: pluginItem.icon,
|
||||||
|
onClick: () => pluginItem.onClick(asset.path, parentPath),
|
||||||
|
disabled: pluginItem.disabled,
|
||||||
|
separator: pluginItem.separator
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({ label: '', separator: true, onClick: () => {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在文件管理器中显示
|
// 在文件管理器中显示
|
||||||
@@ -238,11 +358,14 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
|
|||||||
return crumbs;
|
return crumbs;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredAssets = searchQuery
|
const filteredAssets = searchQuery.trim() ? searchResults : assets;
|
||||||
? assets.filter((asset) =>
|
|
||||||
asset.name.toLowerCase().includes(searchQuery.toLowerCase())
|
const getRelativePath = (fullPath: string): string => {
|
||||||
)
|
if (!projectPath) return fullPath;
|
||||||
: assets;
|
const relativePath = fullPath.replace(projectPath, '').replace(/^[/\\]/, '');
|
||||||
|
const parts = relativePath.split(/[/\\]/);
|
||||||
|
return parts.slice(0, -1).join('/');
|
||||||
|
};
|
||||||
|
|
||||||
const getFileIcon = (asset: AssetItem) => {
|
const getFileIcon = (asset: AssetItem) => {
|
||||||
if (asset.type === 'folder') {
|
if (asset.type === 'folder') {
|
||||||
@@ -289,11 +412,84 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="asset-browser">
|
<div className="asset-browser">
|
||||||
<div className="asset-browser-header">
|
|
||||||
<h3>{t.title}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="asset-browser-content">
|
<div className="asset-browser-content">
|
||||||
|
<div style={{
|
||||||
|
padding: '8px',
|
||||||
|
borderBottom: '1px solid #3e3e3e',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
background: '#252526',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDetailView(true);
|
||||||
|
localStorage.setItem('asset-browser-detail-view', 'true');
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: showDetailView ? '#0e639c' : 'transparent',
|
||||||
|
border: '1px solid #3e3e3e',
|
||||||
|
borderRadius: '3px',
|
||||||
|
color: showDetailView ? '#ffffff' : '#cccccc',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: showDetailView ? '500' : 'normal'
|
||||||
|
}}
|
||||||
|
title="显示详细视图(树形图 + 资产列表)"
|
||||||
|
>
|
||||||
|
<LayoutGrid size={14} />
|
||||||
|
详细视图
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDetailView(false);
|
||||||
|
localStorage.setItem('asset-browser-detail-view', 'false');
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: !showDetailView ? '#0e639c' : 'transparent',
|
||||||
|
border: '1px solid #3e3e3e',
|
||||||
|
borderRadius: '3px',
|
||||||
|
color: !showDetailView ? '#ffffff' : '#cccccc',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: !showDetailView ? '500' : 'normal'
|
||||||
|
}}
|
||||||
|
title="仅显示树形图(查看完整路径)"
|
||||||
|
>
|
||||||
|
<List size={14} />
|
||||||
|
树形图
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="asset-search"
|
||||||
|
placeholder={t.search}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 10px',
|
||||||
|
background: '#3c3c3c',
|
||||||
|
border: '1px solid #3e3e3e',
|
||||||
|
borderRadius: '3px',
|
||||||
|
color: '#cccccc',
|
||||||
|
fontSize: '12px',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showDetailView ? (
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
direction="horizontal"
|
direction="horizontal"
|
||||||
defaultSize={200}
|
defaultSize={200}
|
||||||
@@ -305,6 +501,9 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
|
|||||||
rootPath={projectPath}
|
rootPath={projectPath}
|
||||||
onSelectFile={handleFolderSelect}
|
onSelectFile={handleFolderSelect}
|
||||||
selectedPath={currentPath}
|
selectedPath={currentPath}
|
||||||
|
messageHub={messageHub}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
showFiles={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -326,26 +525,20 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="asset-browser-toolbar">
|
{(loading || isSearching) ? (
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="asset-search"
|
|
||||||
placeholder={t.search}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<div className="asset-browser-loading">
|
<div className="asset-browser-loading">
|
||||||
<p>{t.loading}</p>
|
<p>{isSearching ? '搜索中...' : t.loading}</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredAssets.length === 0 ? (
|
) : filteredAssets.length === 0 ? (
|
||||||
<div className="asset-browser-empty">
|
<div className="asset-browser-empty">
|
||||||
<p>{t.empty}</p>
|
<p>{searchQuery.trim() ? '未找到匹配的资产' : t.empty}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="asset-list">
|
<div className="asset-list">
|
||||||
{filteredAssets.map((asset, index) => (
|
{filteredAssets.map((asset, index) => {
|
||||||
|
const relativePath = getRelativePath(asset.path);
|
||||||
|
const showPath = searchQuery.trim() && relativePath;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
|
className={`asset-item ${selectedPath === asset.path ? 'selected' : ''}`}
|
||||||
@@ -354,19 +547,43 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT
|
|||||||
onContextMenu={(e) => handleContextMenu(e, asset)}
|
onContextMenu={(e) => handleContextMenu(e, asset)}
|
||||||
>
|
>
|
||||||
{getFileIcon(asset)}
|
{getFileIcon(asset)}
|
||||||
<div className="asset-name" title={asset.name}>
|
<div className="asset-info">
|
||||||
|
<div className="asset-name" title={asset.path}>
|
||||||
{asset.name}
|
{asset.name}
|
||||||
</div>
|
</div>
|
||||||
|
{showPath && (
|
||||||
|
<div className="asset-path" style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#666',
|
||||||
|
marginTop: '2px'
|
||||||
|
}}>
|
||||||
|
{relativePath}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="asset-type">
|
<div className="asset-type">
|
||||||
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
|
{asset.type === 'folder' ? t.folder : (asset.extension || t.file)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="asset-browser-tree-only">
|
||||||
|
<FileTree
|
||||||
|
rootPath={projectPath}
|
||||||
|
onSelectFile={handleFolderSelect}
|
||||||
|
selectedPath={currentPath}
|
||||||
|
messageHub={messageHub}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
showFiles={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export const BehaviorTreeBlackboard: React.FC<BehaviorTreeBlackboardProps> = ({
|
|||||||
|
|
||||||
{/* 标题栏 */}
|
{/* 标题栏 */}
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: '#2d2d2d',
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
borderBottom: '1px solid #333'
|
borderBottom: '1px solid #333'
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { NodeTemplate } from '@esengine/behavior-tree';
|
|||||||
import { RotateCcw } from 'lucide-react';
|
import { RotateCcw } from 'lucide-react';
|
||||||
import { useBehaviorTreeStore, BehaviorTreeNode, ROOT_NODE_ID } from '../stores/behaviorTreeStore';
|
import { useBehaviorTreeStore, BehaviorTreeNode, ROOT_NODE_ID } from '../stores/behaviorTreeStore';
|
||||||
import { useUIStore } from '../application/state/UIStore';
|
import { useUIStore } from '../application/state/UIStore';
|
||||||
import { BehaviorTreeExecutionPanel } from './BehaviorTreeExecutionPanel';
|
|
||||||
import { useToast } from './Toast';
|
import { useToast } from './Toast';
|
||||||
import { BlackboardValue } from '../domain/models/Blackboard';
|
import { BlackboardValue } from '../domain/models/Blackboard';
|
||||||
import { BehaviorTreeCanvas } from '../presentation/components/behavior-tree/canvas/BehaviorTreeCanvas';
|
import { BehaviorTreeCanvas } from '../presentation/components/behavior-tree/canvas/BehaviorTreeCanvas';
|
||||||
@@ -39,13 +38,15 @@ interface BehaviorTreeEditorProps {
|
|||||||
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
|
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
|
||||||
blackboardVariables?: BlackboardVariables;
|
blackboardVariables?: BlackboardVariables;
|
||||||
projectPath?: string | null;
|
projectPath?: string | null;
|
||||||
|
showToolbar?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
||||||
onNodeSelect,
|
onNodeSelect,
|
||||||
onNodeCreate,
|
onNodeCreate,
|
||||||
blackboardVariables = {},
|
blackboardVariables = {},
|
||||||
projectPath = null
|
projectPath = null,
|
||||||
|
showToolbar = true
|
||||||
}) => {
|
}) => {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
@@ -75,7 +76,11 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
|||||||
setInitialBlackboardVariables,
|
setInitialBlackboardVariables,
|
||||||
setIsExecuting,
|
setIsExecuting,
|
||||||
initialBlackboardVariables,
|
initialBlackboardVariables,
|
||||||
isExecuting
|
isExecuting,
|
||||||
|
saveNodesDataSnapshot,
|
||||||
|
restoreNodesData,
|
||||||
|
nodeExecutionStatuses,
|
||||||
|
nodeExecutionOrders
|
||||||
} = useBehaviorTreeStore();
|
} = useBehaviorTreeStore();
|
||||||
|
|
||||||
// UI store(选中、拖拽、画布状态)
|
// UI store(选中、拖拽、画布状态)
|
||||||
@@ -107,7 +112,7 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
|||||||
const connectionOperations = useConnectionOperations(validator, commandManager);
|
const connectionOperations = useConnectionOperations(validator, commandManager);
|
||||||
|
|
||||||
// 右键菜单
|
// 右键菜单
|
||||||
const { contextMenu, setContextMenu, handleNodeContextMenu, closeContextMenu } = useContextMenu();
|
const { contextMenu, setContextMenu, handleNodeContextMenu, handleCanvasContextMenu, closeContextMenu } = useContextMenu();
|
||||||
|
|
||||||
// 组件挂载和连线变化时强制更新,确保连线能正确渲染
|
// 组件挂载和连线变化时强制更新,确保连线能正确渲染
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -164,7 +169,9 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
|||||||
initialBlackboardVariables,
|
initialBlackboardVariables,
|
||||||
onBlackboardUpdate: setBlackboardVariables,
|
onBlackboardUpdate: setBlackboardVariables,
|
||||||
onInitialBlackboardSave: setInitialBlackboardVariables,
|
onInitialBlackboardSave: setInitialBlackboardVariables,
|
||||||
onExecutingChange: setIsExecuting
|
onExecutingChange: setIsExecuting,
|
||||||
|
onSaveNodesDataSnapshot: saveNodesDataSnapshot,
|
||||||
|
onRestoreNodesData: restoreNodesData
|
||||||
});
|
});
|
||||||
|
|
||||||
executorRef.current = controller['executor'] || null;
|
executorRef.current = controller['executor'] || null;
|
||||||
@@ -296,7 +303,8 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
|||||||
setSelectedConnection,
|
setSelectedConnection,
|
||||||
setQuickCreateMenu,
|
setQuickCreateMenu,
|
||||||
clearConnecting,
|
clearConnecting,
|
||||||
clearBoxSelect
|
clearBoxSelect,
|
||||||
|
showToast
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -373,6 +381,7 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
|||||||
handleNodeMouseUp();
|
handleNodeMouseUp();
|
||||||
handleCanvasMouseUp(e);
|
handleCanvasMouseUp(e);
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={handleCanvasContextMenu}
|
||||||
>
|
>
|
||||||
{/* 连接线层 */}
|
{/* 连接线层 */}
|
||||||
<ConnectionLayer
|
<ConnectionLayer
|
||||||
@@ -473,6 +482,8 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
|||||||
{nodes.map((node: BehaviorTreeNode) => {
|
{nodes.map((node: BehaviorTreeNode) => {
|
||||||
const isSelected = selectedNodeIds.includes(node.id);
|
const isSelected = selectedNodeIds.includes(node.id);
|
||||||
const isBeingDragged = dragStartPositions.has(node.id);
|
const isBeingDragged = dragStartPositions.has(node.id);
|
||||||
|
const executionStatus = nodeExecutionStatuses.get(node.id);
|
||||||
|
const executionOrder = nodeExecutionOrders.get(node.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BehaviorTreeNodeComponent
|
<BehaviorTreeNodeComponent
|
||||||
@@ -485,6 +496,8 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
|||||||
blackboardVariables={blackboardVariables}
|
blackboardVariables={blackboardVariables}
|
||||||
initialBlackboardVariables={initialBlackboardVariables}
|
initialBlackboardVariables={initialBlackboardVariables}
|
||||||
isExecuting={isExecuting}
|
isExecuting={isExecuting}
|
||||||
|
executionStatus={executionStatus}
|
||||||
|
executionOrder={executionOrder}
|
||||||
connections={connections}
|
connections={connections}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
executorRef={executorRef}
|
executorRef={executorRef}
|
||||||
@@ -543,6 +556,7 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
|||||||
</BehaviorTreeCanvas>
|
</BehaviorTreeCanvas>
|
||||||
|
|
||||||
{/* 运行控制工具栏 */}
|
{/* 运行控制工具栏 */}
|
||||||
|
{showToolbar && (
|
||||||
<EditorToolbar
|
<EditorToolbar
|
||||||
executionMode={executionMode}
|
executionMode={executionMode}
|
||||||
canUndo={canUndo}
|
canUndo={canUndo}
|
||||||
@@ -557,6 +571,7 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
|||||||
onResetView={handleResetView}
|
onResetView={handleResetView}
|
||||||
onClearCanvas={handleClearCanvas}
|
onClearCanvas={handleClearCanvas}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 快速创建菜单 */}
|
{/* 快速创建菜单 */}
|
||||||
<QuickCreateMenu
|
<QuickCreateMenu
|
||||||
@@ -566,14 +581,14 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
|||||||
selectedIndex={quickCreateMenu.selectedIndex}
|
selectedIndex={quickCreateMenu.selectedIndex}
|
||||||
mode={quickCreateMenu.mode}
|
mode={quickCreateMenu.mode}
|
||||||
iconMap={ICON_MAP}
|
iconMap={ICON_MAP}
|
||||||
onSearchChange={(text) => setQuickCreateMenu({
|
onSearchChange={(text) => setQuickCreateMenu(prev => ({
|
||||||
...quickCreateMenu,
|
...prev,
|
||||||
searchText: text
|
searchText: text
|
||||||
})}
|
}))}
|
||||||
onIndexChange={(index) => setQuickCreateMenu({
|
onIndexChange={(index) => setQuickCreateMenu(prev => ({
|
||||||
...quickCreateMenu,
|
...prev,
|
||||||
selectedIndex: index
|
selectedIndex: index
|
||||||
})}
|
}))}
|
||||||
onNodeSelect={handleQuickCreateNode}
|
onNodeSelect={handleQuickCreateNode}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setQuickCreateMenu({
|
setQuickCreateMenu({
|
||||||
@@ -615,21 +630,6 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 执行面板 */}
|
|
||||||
<div style={{
|
|
||||||
height: '250px',
|
|
||||||
borderTop: '1px solid #333'
|
|
||||||
}}>
|
|
||||||
<BehaviorTreeExecutionPanel
|
|
||||||
logs={executionLogs}
|
|
||||||
onClearLogs={() => setExecutionLogs([])}
|
|
||||||
isRunning={executionMode === 'running'}
|
|
||||||
tickCount={tickCount}
|
|
||||||
executionSpeed={executionSpeed}
|
|
||||||
onSpeedChange={handleSpeedChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右键菜单 */}
|
{/* 右键菜单 */}
|
||||||
<NodeContextMenu
|
<NodeContextMenu
|
||||||
visible={contextMenu.visible}
|
visible={contextMenu.visible}
|
||||||
@@ -646,6 +646,23 @@ export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
|
|||||||
});
|
});
|
||||||
setContextMenu({ ...contextMenu, visible: false });
|
setContextMenu({ ...contextMenu, visible: false });
|
||||||
}}
|
}}
|
||||||
|
onDeleteNode={() => {
|
||||||
|
if (contextMenu.nodeId) {
|
||||||
|
nodeOperations.deleteNode(contextMenu.nodeId);
|
||||||
|
setContextMenu({ ...contextMenu, visible: false });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCreateNode={() => {
|
||||||
|
setQuickCreateMenu({
|
||||||
|
visible: true,
|
||||||
|
position: contextMenu.position,
|
||||||
|
searchText: '',
|
||||||
|
selectedIndex: 0,
|
||||||
|
mode: 'create',
|
||||||
|
replaceNodeId: null
|
||||||
|
});
|
||||||
|
setContextMenu({ ...contextMenu, visible: false });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProp
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
|
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
|
||||||
const [assetPickerProperty, setAssetPickerProperty] = useState<string | null>(null);
|
const [assetPickerProperty, setAssetPickerProperty] = useState<string | null>(null);
|
||||||
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
|
const [localValues, setLocalValues] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// 当节点切换时,清空本地状态
|
||||||
|
React.useEffect(() => {
|
||||||
|
setLocalValues({});
|
||||||
|
}, [selectedNode?.template.className]);
|
||||||
|
|
||||||
if (!selectedNode) {
|
if (!selectedNode) {
|
||||||
return (
|
return (
|
||||||
@@ -58,11 +65,31 @@ export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProp
|
|||||||
const { template, data } = selectedNode;
|
const { template, data } = selectedNode;
|
||||||
|
|
||||||
const handleChange = (propName: string, value: any) => {
|
const handleChange = (propName: string, value: any) => {
|
||||||
|
if (!isComposing) {
|
||||||
|
onPropertyChange?.(propName, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (propName: string, value: any) => {
|
||||||
|
setLocalValues(prev => ({ ...prev, [propName]: value }));
|
||||||
|
if (!isComposing) {
|
||||||
|
onPropertyChange?.(propName, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompositionStart = () => {
|
||||||
|
setIsComposing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompositionEnd = (propName: string, value: any) => {
|
||||||
|
setIsComposing(false);
|
||||||
onPropertyChange?.(propName, value);
|
onPropertyChange?.(propName, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderProperty = (prop: PropertyDefinition) => {
|
const renderProperty = (prop: PropertyDefinition) => {
|
||||||
const value = data[prop.name] ?? prop.defaultValue;
|
const propName = prop.name;
|
||||||
|
const hasLocalValue = propName in localValues;
|
||||||
|
const value = hasLocalValue ? localValues[propName] : (data[prop.name] ?? prop.defaultValue);
|
||||||
|
|
||||||
switch (prop.type) {
|
switch (prop.type) {
|
||||||
case 'string':
|
case 'string':
|
||||||
@@ -71,7 +98,10 @@ export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProp
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={(e) => handleChange(prop.name, e.target.value)}
|
onChange={(e) => handleInputChange(propName, e.target.value)}
|
||||||
|
onCompositionStart={handleCompositionStart}
|
||||||
|
onCompositionEnd={(e) => handleCompositionEnd(propName, (e.target as HTMLInputElement).value)}
|
||||||
|
onBlur={(e) => onPropertyChange?.(propName, e.target.value)}
|
||||||
placeholder={prop.description}
|
placeholder={prop.description}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -148,7 +178,10 @@ export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProp
|
|||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={(e) => handleChange(prop.name, e.target.value)}
|
onChange={(e) => handleInputChange(propName, e.target.value)}
|
||||||
|
onCompositionStart={handleCompositionStart}
|
||||||
|
onCompositionEnd={(e) => handleCompositionEnd(propName, (e.target as HTMLTextAreaElement).value)}
|
||||||
|
onBlur={(e) => onPropertyChange?.(propName, e.target.value)}
|
||||||
placeholder={prop.description}
|
placeholder={prop.description}
|
||||||
rows={5}
|
rows={5}
|
||||||
style={{
|
style={{
|
||||||
@@ -171,7 +204,10 @@ export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProp
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={(e) => handleChange(prop.name, e.target.value)}
|
onChange={(e) => handleInputChange(propName, e.target.value)}
|
||||||
|
onCompositionStart={handleCompositionStart}
|
||||||
|
onCompositionEnd={(e) => handleCompositionEnd(propName, (e.target as HTMLInputElement).value)}
|
||||||
|
onBlur={(e) => onPropertyChange?.(propName, e.target.value)}
|
||||||
placeholder="黑板变量名"
|
placeholder="黑板变量名"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -340,43 +376,6 @@ export const BehaviorTreeNodeProperties: React.FC<BehaviorTreeNodePropertiesProp
|
|||||||
)}
|
)}
|
||||||
</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 && (
|
{assetPickerOpen && projectPath && assetPickerProperty && (
|
||||||
<AssetPickerDialog
|
<AssetPickerDialog
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Folder, ChevronRight, ChevronDown } from 'lucide-react';
|
import { Folder, ChevronRight, ChevronDown, File, Edit3, Trash2, FolderOpen, Copy, FileText, FolderPlus, ChevronsDown, ChevronsUp } from 'lucide-react';
|
||||||
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
import { TauriAPI, DirectoryEntry } from '../api/tauri';
|
||||||
|
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
|
||||||
|
import { Core } from '@esengine/ecs-framework';
|
||||||
|
import { ContextMenu, ContextMenuItem } from './ContextMenu';
|
||||||
|
import { ConfirmDialog } from './ConfirmDialog';
|
||||||
|
import { PromptDialog } from './PromptDialog';
|
||||||
import '../styles/FileTree.css';
|
import '../styles/FileTree.css';
|
||||||
|
|
||||||
interface TreeNode {
|
interface TreeNode {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
type: 'folder';
|
type: 'folder' | 'file';
|
||||||
|
size?: number;
|
||||||
|
modified?: number;
|
||||||
children?: TreeNode[];
|
children?: TreeNode[];
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
loaded?: boolean;
|
loaded?: boolean;
|
||||||
@@ -16,11 +23,30 @@ interface FileTreeProps {
|
|||||||
rootPath: string | null;
|
rootPath: string | null;
|
||||||
onSelectFile?: (path: string) => void;
|
onSelectFile?: (path: string) => void;
|
||||||
selectedPath?: string | null;
|
selectedPath?: string | null;
|
||||||
|
messageHub?: MessageHub;
|
||||||
|
searchQuery?: string;
|
||||||
|
showFiles?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps) {
|
export function FileTree({ rootPath, onSelectFile, selectedPath, messageHub, searchQuery, showFiles = true }: FileTreeProps) {
|
||||||
const [tree, setTree] = useState<TreeNode[]>([]);
|
const [tree, setTree] = useState<TreeNode[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [internalSelectedPath, setInternalSelectedPath] = useState<string | null>(null);
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
position: { x: number; y: number };
|
||||||
|
node: TreeNode | null;
|
||||||
|
} | null>(null);
|
||||||
|
const [renamingNode, setRenamingNode] = useState<string | null>(null);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [deleteDialog, setDeleteDialog] = useState<{ node: TreeNode } | null>(null);
|
||||||
|
const [promptDialog, setPromptDialog] = useState<{
|
||||||
|
type: 'create-file' | 'create-folder' | 'create-template';
|
||||||
|
parentPath: string;
|
||||||
|
templateExtension?: string;
|
||||||
|
templateContent?: (fileName: string) => Promise<string>;
|
||||||
|
} | null>(null);
|
||||||
|
const [filteredTree, setFilteredTree] = useState<TreeNode[]>([]);
|
||||||
|
const fileActionRegistry = Core.services.resolve(FileActionRegistry);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rootPath) {
|
if (rootPath) {
|
||||||
@@ -30,6 +56,74 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
|
|||||||
}
|
}
|
||||||
}, [rootPath]);
|
}, [rootPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPath) {
|
||||||
|
setInternalSelectedPath(selectedPath);
|
||||||
|
}
|
||||||
|
}, [selectedPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const performSearch = async () => {
|
||||||
|
const filterByFileType = (nodes: TreeNode[]): TreeNode[] => {
|
||||||
|
return nodes
|
||||||
|
.filter(node => showFiles || node.type === 'folder')
|
||||||
|
.map(node => ({
|
||||||
|
...node,
|
||||||
|
children: node.children ? filterByFileType(node.children) : node.children
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = filterByFileType(tree);
|
||||||
|
|
||||||
|
if (searchQuery && searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
|
||||||
|
const loadAndFilterTree = async (nodes: TreeNode[]): Promise<TreeNode[]> => {
|
||||||
|
const filtered: TreeNode[] = [];
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const nameMatches = node.name.toLowerCase().includes(query);
|
||||||
|
let filteredChildren: TreeNode[] = [];
|
||||||
|
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
let childrenToSearch = node.children || [];
|
||||||
|
|
||||||
|
if (!node.loaded) {
|
||||||
|
try {
|
||||||
|
const entries = await TauriAPI.listDirectory(node.path);
|
||||||
|
childrenToSearch = entriesToNodes(entries);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load children for search:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childrenToSearch.length > 0) {
|
||||||
|
filteredChildren = await loadAndFilterTree(childrenToSearch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameMatches || filteredChildren.length > 0) {
|
||||||
|
filtered.push({
|
||||||
|
...node,
|
||||||
|
expanded: filteredChildren.length > 0,
|
||||||
|
loaded: true,
|
||||||
|
children: filteredChildren.length > 0 ? filteredChildren : (node.type === 'folder' ? [] : undefined)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
};
|
||||||
|
|
||||||
|
result = await loadAndFilterTree(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredTree(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
performSearch();
|
||||||
|
}, [searchQuery, tree, showFiles]);
|
||||||
|
|
||||||
const loadRootDirectory = async (path: string) => {
|
const loadRootDirectory = async (path: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -57,16 +151,20 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
|
|||||||
};
|
};
|
||||||
|
|
||||||
const entriesToNodes = (entries: DirectoryEntry[]): TreeNode[] => {
|
const entriesToNodes = (entries: DirectoryEntry[]): TreeNode[] => {
|
||||||
// 只显示文件夹,过滤掉文件
|
|
||||||
return entries
|
return entries
|
||||||
.filter((entry) => entry.is_dir)
|
.sort((a, b) => {
|
||||||
|
if (a.is_dir === b.is_dir) return a.name.localeCompare(b.name);
|
||||||
|
return a.is_dir ? -1 : 1;
|
||||||
|
})
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
path: entry.path,
|
path: entry.path,
|
||||||
type: 'folder' as const,
|
type: entry.is_dir ? 'folder' as const : 'file' as const,
|
||||||
children: [],
|
size: entry.size,
|
||||||
|
modified: entry.modified,
|
||||||
|
children: entry.is_dir ? [] : undefined,
|
||||||
expanded: false,
|
expanded: false,
|
||||||
loaded: false
|
loaded: entry.is_dir ? false : undefined
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,13 +213,343 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
|
|||||||
setTree(newTree);
|
setTree(newTree);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshTree = async () => {
|
||||||
|
if (rootPath) {
|
||||||
|
await loadRootDirectory(rootPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandAll = async () => {
|
||||||
|
const expandNode = async (node: TreeNode): Promise<TreeNode> => {
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
let children = node.children || [];
|
||||||
|
|
||||||
|
if (!node.loaded) {
|
||||||
|
try {
|
||||||
|
const entries = await TauriAPI.listDirectory(node.path);
|
||||||
|
children = entriesToNodes(entries);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load children:', error);
|
||||||
|
children = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedChildren = await Promise.all(
|
||||||
|
children.map(child => expandNode(child))
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
expanded: true,
|
||||||
|
loaded: true,
|
||||||
|
children: expandedChildren
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandedTree = await Promise.all(tree.map(node => expandNode(node)));
|
||||||
|
setTree(expandedTree);
|
||||||
|
};
|
||||||
|
|
||||||
|
const collapseAll = () => {
|
||||||
|
const collapseNode = (node: TreeNode): TreeNode => {
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
expanded: false,
|
||||||
|
children: node.children ? node.children.map(collapseNode) : node.children
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collapsedTree = tree.map(node => collapseNode(node));
|
||||||
|
setTree(collapsedTree);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async (node: TreeNode) => {
|
||||||
|
if (!newName || newName === node.name) {
|
||||||
|
setRenamingNode(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathParts = node.path.split(/[/\\]/);
|
||||||
|
pathParts[pathParts.length - 1] = newName;
|
||||||
|
const newPath = pathParts.join('/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await TauriAPI.renameFileOrFolder(node.path, newPath);
|
||||||
|
await refreshTree();
|
||||||
|
setRenamingNode(null);
|
||||||
|
setNewName('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to rename:', error);
|
||||||
|
alert(`重命名失败: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (node: TreeNode) => {
|
||||||
|
setContextMenu(null);
|
||||||
|
setDeleteDialog({ node });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteDialog) return;
|
||||||
|
|
||||||
|
const node = deleteDialog.node;
|
||||||
|
setDeleteDialog(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
await TauriAPI.deleteFolder(node.path);
|
||||||
|
} else {
|
||||||
|
await TauriAPI.deleteFile(node.path);
|
||||||
|
}
|
||||||
|
await refreshTree();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete:', error);
|
||||||
|
alert(`删除失败: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateFileClick = (parentPath: string) => {
|
||||||
|
setContextMenu(null);
|
||||||
|
setPromptDialog({ type: 'create-file', parentPath });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateFolderClick = (parentPath: string) => {
|
||||||
|
setContextMenu(null);
|
||||||
|
setPromptDialog({ type: 'create-folder', parentPath });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTemplateFileClick = (parentPath: string, template: any) => {
|
||||||
|
setContextMenu(null);
|
||||||
|
setPromptDialog({
|
||||||
|
type: 'create-template',
|
||||||
|
parentPath,
|
||||||
|
templateExtension: template.extension,
|
||||||
|
templateContent: template.createContent
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePromptConfirm = async (value: string) => {
|
||||||
|
if (!promptDialog) return;
|
||||||
|
|
||||||
|
const { type, parentPath, templateExtension, templateContent } = promptDialog;
|
||||||
|
setPromptDialog(null);
|
||||||
|
|
||||||
|
let fileName = value;
|
||||||
|
let targetPath = `${parentPath}/${value}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (type === 'create-file') {
|
||||||
|
await TauriAPI.createFile(targetPath);
|
||||||
|
} else if (type === 'create-folder') {
|
||||||
|
await TauriAPI.createDirectory(targetPath);
|
||||||
|
} else if (type === 'create-template' && templateExtension && templateContent) {
|
||||||
|
if (!fileName.endsWith(`.${templateExtension}`)) {
|
||||||
|
fileName = `${fileName}.${templateExtension}`;
|
||||||
|
targetPath = `${parentPath}/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await templateContent(fileName);
|
||||||
|
await TauriAPI.writeFileContent(targetPath, content);
|
||||||
|
}
|
||||||
|
await refreshTree();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to ${type}:`, error);
|
||||||
|
alert(`${type === 'create-file' ? '创建文件' : type === 'create-folder' ? '创建文件夹' : '创建模板文件'}失败: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContextMenuItems = (node: TreeNode | null): ContextMenuItem[] => {
|
||||||
|
if (!node) {
|
||||||
|
const baseItems: ContextMenuItem[] = [
|
||||||
|
{
|
||||||
|
label: '新建文件',
|
||||||
|
icon: <FileText size={16} />,
|
||||||
|
onClick: () => rootPath && handleCreateFileClick(rootPath)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '新建文件夹',
|
||||||
|
icon: <FolderPlus size={16} />,
|
||||||
|
onClick: () => rootPath && handleCreateFolderClick(rootPath)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (fileActionRegistry && rootPath) {
|
||||||
|
const templates = fileActionRegistry.getCreationTemplates();
|
||||||
|
if (templates.length > 0) {
|
||||||
|
baseItems.push({ label: '', separator: true, onClick: () => {} });
|
||||||
|
for (const template of templates) {
|
||||||
|
baseItems.push({
|
||||||
|
label: template.label,
|
||||||
|
icon: template.icon,
|
||||||
|
onClick: () => handleCreateTemplateFileClick(rootPath, template)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: ContextMenuItem[] = [];
|
||||||
|
|
||||||
|
if (node.type === 'file') {
|
||||||
|
items.push({
|
||||||
|
label: '打开文件',
|
||||||
|
icon: <File size={16} />,
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await TauriAPI.openFileWithSystemApp(node.path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fileActionRegistry) {
|
||||||
|
const handlers = fileActionRegistry.getHandlersForFile(node.path);
|
||||||
|
for (const handler of handlers) {
|
||||||
|
if (handler.getContextMenuItems) {
|
||||||
|
const parentPath = node.path.substring(0, node.path.lastIndexOf('/'));
|
||||||
|
const pluginItems = handler.getContextMenuItems(node.path, parentPath);
|
||||||
|
for (const pluginItem of pluginItems) {
|
||||||
|
items.push({
|
||||||
|
label: pluginItem.label,
|
||||||
|
icon: pluginItem.icon,
|
||||||
|
onClick: () => pluginItem.onClick(node.path, parentPath),
|
||||||
|
disabled: pluginItem.disabled,
|
||||||
|
separator: pluginItem.separator
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: '重命名',
|
||||||
|
icon: <Edit3 size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
setRenamingNode(node.path);
|
||||||
|
setNewName(node.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: '删除',
|
||||||
|
icon: <Trash2 size={16} />,
|
||||||
|
onClick: () => handleDeleteClick(node)
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push({ label: '', separator: true, onClick: () => {} });
|
||||||
|
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
items.push({
|
||||||
|
label: '新建文件',
|
||||||
|
icon: <FileText size={16} />,
|
||||||
|
onClick: () => handleCreateFileClick(node.path)
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: '新建文件夹',
|
||||||
|
icon: <FolderPlus size={16} />,
|
||||||
|
onClick: () => handleCreateFolderClick(node.path)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fileActionRegistry) {
|
||||||
|
const templates = fileActionRegistry.getCreationTemplates();
|
||||||
|
if (templates.length > 0) {
|
||||||
|
items.push({ label: '', separator: true, onClick: () => {} });
|
||||||
|
for (const template of templates) {
|
||||||
|
items.push({
|
||||||
|
label: template.label,
|
||||||
|
icon: template.icon,
|
||||||
|
onClick: () => handleCreateTemplateFileClick(node.path, template)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({ label: '', separator: true, onClick: () => {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: '在文件管理器中显示',
|
||||||
|
icon: <FolderOpen size={16} />,
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await TauriAPI.showInFolder(node.path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to show in folder:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: '复制路径',
|
||||||
|
icon: <Copy size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
navigator.clipboard.writeText(node.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
const handleNodeClick = (node: TreeNode) => {
|
const handleNodeClick = (node: TreeNode) => {
|
||||||
|
if (node.type === 'folder') {
|
||||||
|
setInternalSelectedPath(node.path);
|
||||||
onSelectFile?.(node.path);
|
onSelectFile?.(node.path);
|
||||||
toggleNode(node.path);
|
toggleNode(node.path);
|
||||||
|
} else {
|
||||||
|
setInternalSelectedPath(node.path);
|
||||||
|
const extension = node.name.includes('.') ? node.name.split('.').pop() : undefined;
|
||||||
|
messageHub?.publish('asset-file:selected', {
|
||||||
|
fileInfo: {
|
||||||
|
name: node.name,
|
||||||
|
path: node.path,
|
||||||
|
extension,
|
||||||
|
size: node.size,
|
||||||
|
modified: node.modified,
|
||||||
|
isDirectory: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodeDoubleClick = async (node: TreeNode) => {
|
||||||
|
if (node.type === 'file') {
|
||||||
|
if (fileActionRegistry) {
|
||||||
|
const handled = await fileActionRegistry.handleDoubleClick(node.path);
|
||||||
|
if (handled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await TauriAPI.openFileWithSystemApp(node.path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (e: React.MouseEvent, node: TreeNode | null) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setContextMenu({
|
||||||
|
position: { x: e.clientX, y: e.clientY },
|
||||||
|
node
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderNode = (node: TreeNode, level: number = 0) => {
|
const renderNode = (node: TreeNode, level: number = 0) => {
|
||||||
const isSelected = selectedPath === node.path;
|
const isSelected = (internalSelectedPath || selectedPath) === node.path;
|
||||||
|
const isRenaming = renamingNode === node.path;
|
||||||
const indent = level * 16;
|
const indent = level * 16;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,17 +557,46 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
|
|||||||
<div
|
<div
|
||||||
className={`tree-node ${isSelected ? 'selected' : ''}`}
|
className={`tree-node ${isSelected ? 'selected' : ''}`}
|
||||||
style={{ paddingLeft: `${indent}px` }}
|
style={{ paddingLeft: `${indent}px` }}
|
||||||
onClick={() => handleNodeClick(node)}
|
onClick={() => !isRenaming && handleNodeClick(node)}
|
||||||
|
onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, node)}
|
||||||
>
|
>
|
||||||
<span className="tree-arrow">
|
<span className="tree-arrow">
|
||||||
{node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
{node.type === 'folder' ? (
|
||||||
|
node.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />
|
||||||
|
) : (
|
||||||
|
<span style={{ width: '14px', display: 'inline-block' }} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="tree-icon">
|
<span className="tree-icon">
|
||||||
<Folder size={16} />
|
{node.type === 'folder' ? (
|
||||||
|
<Folder size={16} style={{ color: '#ffa726' }} />
|
||||||
|
) : (
|
||||||
|
<File size={16} style={{ color: '#90caf9' }} />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
{isRenaming ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="tree-rename-input"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onBlur={() => handleRename(node)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleRename(node);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setRenamingNode(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<span className="tree-label">{node.name}</span>
|
<span className="tree-label">{node.name}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{node.expanded && node.children && (
|
{node.type === 'folder' && node.expanded && node.children && (
|
||||||
<div className="tree-children">
|
<div className="tree-children">
|
||||||
{node.children.map((child) => renderNode(child, level + 1))}
|
{node.children.map((child) => renderNode(child, level + 1))}
|
||||||
</div>
|
</div>
|
||||||
@@ -157,8 +614,78 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="file-tree">
|
<>
|
||||||
{tree.map((node) => renderNode(node))}
|
<div className="file-tree-toolbar">
|
||||||
|
<button
|
||||||
|
className="file-tree-toolbar-btn"
|
||||||
|
onClick={expandAll}
|
||||||
|
title="展开全部文件夹"
|
||||||
|
>
|
||||||
|
<ChevronsDown size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="file-tree-toolbar-btn"
|
||||||
|
onClick={collapseAll}
|
||||||
|
title="收缩全部文件夹"
|
||||||
|
>
|
||||||
|
<ChevronsUp size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="file-tree"
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.classList.contains('file-tree')) {
|
||||||
|
handleContextMenu(e, null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredTree.map((node) => renderNode(node))}
|
||||||
|
</div>
|
||||||
|
{contextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
items={getContextMenuItems(contextMenu.node)}
|
||||||
|
position={contextMenu.position}
|
||||||
|
onClose={() => setContextMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{deleteDialog && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="确认删除"
|
||||||
|
message={
|
||||||
|
deleteDialog.node.type === 'folder'
|
||||||
|
? `确定要删除文件夹 "${deleteDialog.node.name}" 及其所有内容吗?\n此操作无法撤销。`
|
||||||
|
: `确定要删除文件 "${deleteDialog.node.name}" 吗?\n此操作无法撤销。`
|
||||||
|
}
|
||||||
|
confirmText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
onCancel={() => setDeleteDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{promptDialog && (
|
||||||
|
<PromptDialog
|
||||||
|
title={
|
||||||
|
promptDialog.type === 'create-file' ? '新建文件' :
|
||||||
|
promptDialog.type === 'create-folder' ? '新建文件夹' :
|
||||||
|
'新建文件'
|
||||||
|
}
|
||||||
|
message={
|
||||||
|
promptDialog.type === 'create-file' ? '请输入文件名:' :
|
||||||
|
promptDialog.type === 'create-folder' ? '请输入文件夹名:' :
|
||||||
|
`请输入文件名 (将自动添加 .${promptDialog.templateExtension} 扩展名):`
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
promptDialog.type === 'create-file' ? '例如: config.json' :
|
||||||
|
promptDialog.type === 'create-folder' ? '例如: assets' :
|
||||||
|
'例如: MyFile'
|
||||||
|
}
|
||||||
|
confirmText="创建"
|
||||||
|
cancelText="取消"
|
||||||
|
onConfirm={handlePromptConfirm}
|
||||||
|
onCancel={() => setPromptDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,17 +17,12 @@ interface FlexLayoutDockContainerProps {
|
|||||||
|
|
||||||
export function FlexLayoutDockContainer({ panels, onPanelClose }: FlexLayoutDockContainerProps) {
|
export function FlexLayoutDockContainer({ panels, onPanelClose }: FlexLayoutDockContainerProps) {
|
||||||
const createDefaultLayout = useCallback((): IJsonModel => {
|
const createDefaultLayout = useCallback((): IJsonModel => {
|
||||||
const leftPanels = panels.filter((p) => p.id.includes('hierarchy'));
|
const hierarchyPanels = panels.filter((p) => p.id.includes('hierarchy'));
|
||||||
|
const assetPanels = panels.filter((p) => p.id.includes('asset'));
|
||||||
const rightPanels = panels.filter((p) => p.id.includes('inspector'));
|
const rightPanels = panels.filter((p) => p.id.includes('inspector'));
|
||||||
const bottomPanels = panels.filter((p) => p.id.includes('console') || p.id.includes('asset'))
|
const bottomPanels = panels.filter((p) => p.id.includes('console'));
|
||||||
.sort((a, b) => {
|
|
||||||
// 控制台排在前面
|
|
||||||
if (a.id.includes('console')) return -1;
|
|
||||||
if (b.id.includes('console')) return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
const centerPanels = panels.filter((p) =>
|
const centerPanels = panels.filter((p) =>
|
||||||
!leftPanels.includes(p) && !rightPanels.includes(p) && !bottomPanels.includes(p)
|
!hierarchyPanels.includes(p) && !assetPanels.includes(p) && !rightPanels.includes(p) && !bottomPanels.includes(p)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build center column children
|
// Build center column children
|
||||||
@@ -61,11 +56,16 @@ export function FlexLayoutDockContainer({ panels, onPanelClose }: FlexLayoutDock
|
|||||||
|
|
||||||
// Build main row children
|
// Build main row children
|
||||||
const mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
|
const mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
|
||||||
if (leftPanels.length > 0) {
|
|
||||||
mainRowChildren.push({
|
// 左侧列:场景层级和资产面板垂直排列(五五分)
|
||||||
|
if (hierarchyPanels.length > 0 || assetPanels.length > 0) {
|
||||||
|
const leftColumnChildren: IJsonTabSetNode[] = [];
|
||||||
|
|
||||||
|
if (hierarchyPanels.length > 0) {
|
||||||
|
leftColumnChildren.push({
|
||||||
type: 'tabset',
|
type: 'tabset',
|
||||||
weight: 20,
|
weight: 50,
|
||||||
children: leftPanels.map((p) => ({
|
children: hierarchyPanels.map((p) => ({
|
||||||
type: 'tab',
|
type: 'tab',
|
||||||
name: p.title,
|
name: p.title,
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@@ -74,6 +74,27 @@ export function FlexLayoutDockContainer({ panels, onPanelClose }: FlexLayoutDock
|
|||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (assetPanels.length > 0) {
|
||||||
|
leftColumnChildren.push({
|
||||||
|
type: 'tabset',
|
||||||
|
weight: 50,
|
||||||
|
children: assetPanels.map((p) => ({
|
||||||
|
type: 'tab',
|
||||||
|
name: p.title,
|
||||||
|
id: p.id,
|
||||||
|
component: p.id,
|
||||||
|
enableClose: p.closable !== false
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mainRowChildren.push({
|
||||||
|
type: 'row',
|
||||||
|
weight: 20,
|
||||||
|
children: leftColumnChildren
|
||||||
|
});
|
||||||
|
}
|
||||||
if (centerColumnChildren.length > 0) {
|
if (centerColumnChildren.length > 0) {
|
||||||
if (centerColumnChildren.length === 1) {
|
if (centerColumnChildren.length === 1) {
|
||||||
const centerChild = centerColumnChildren[0];
|
const centerChild = centerColumnChildren[0];
|
||||||
|
|||||||
646
packages/editor-app/src/components/Inspector.tsx
Normal file
646
packages/editor-app/src/components/Inspector.tsx
Normal file
@@ -0,0 +1,646 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Entity } from '@esengine/ecs-framework';
|
||||||
|
import { EntityStoreService, MessageHub } from '@esengine/editor-core';
|
||||||
|
import { PropertyInspector } from './PropertyInspector';
|
||||||
|
import { BehaviorTreeNodeProperties } from './BehaviorTreeNodeProperties';
|
||||||
|
import { FileSearch, ChevronDown, ChevronRight, X, Settings, Box, AlertTriangle, Copy, File as FileIcon, Folder, Clock, HardDrive } from 'lucide-react';
|
||||||
|
import { BehaviorTreeNode, useBehaviorTreeStore } from '../stores/behaviorTreeStore';
|
||||||
|
import { ICON_MAP } from '../presentation/config/editorConstants';
|
||||||
|
import { useNodeOperations } from '../presentation/hooks/useNodeOperations';
|
||||||
|
import { useCommandHistory } from '../presentation/hooks/useCommandHistory';
|
||||||
|
import { NodeFactory } from '../infrastructure/factories/NodeFactory';
|
||||||
|
import { BehaviorTreeValidator } from '../infrastructure/validation/BehaviorTreeValidator';
|
||||||
|
import { TauriAPI } from '../api/tauri';
|
||||||
|
import '../styles/EntityInspector.css';
|
||||||
|
|
||||||
|
interface InspectorProps {
|
||||||
|
entityStore: EntityStoreService;
|
||||||
|
messageHub: MessageHub;
|
||||||
|
projectPath?: string | null;
|
||||||
|
isExecuting?: boolean;
|
||||||
|
executionMode?: 'idle' | 'running' | 'paused' | 'step';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetFileInfo {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
extension?: string;
|
||||||
|
size?: number;
|
||||||
|
modified?: number;
|
||||||
|
isDirectory: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InspectorTarget =
|
||||||
|
| { type: 'entity'; data: Entity }
|
||||||
|
| { type: 'remote-entity'; data: any; details?: any }
|
||||||
|
| { type: 'behavior-tree-node'; data: BehaviorTreeNode }
|
||||||
|
| { type: 'asset-file'; data: AssetFileInfo; content?: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function Inspector({ entityStore: _entityStore, messageHub, projectPath, isExecuting, executionMode }: InspectorProps) {
|
||||||
|
const [target, setTarget] = useState<InspectorTarget>(null);
|
||||||
|
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(new Set());
|
||||||
|
const [componentVersion, setComponentVersion] = useState(0);
|
||||||
|
|
||||||
|
// 行为树节点操作相关
|
||||||
|
const nodeFactory = useMemo(() => new NodeFactory(), []);
|
||||||
|
const validator = useMemo(() => new BehaviorTreeValidator(), []);
|
||||||
|
const { commandManager } = useCommandHistory();
|
||||||
|
const nodeOperations = useNodeOperations(nodeFactory, validator, commandManager);
|
||||||
|
const { nodes, connections, isExecuting: storeIsExecuting } = useBehaviorTreeStore();
|
||||||
|
|
||||||
|
// 优先使用传入的 isExecuting,否则使用 store 中的
|
||||||
|
const isRunning = isExecuting ?? storeIsExecuting;
|
||||||
|
|
||||||
|
// 当节点数据更新时,同步更新 target 中的节点
|
||||||
|
useEffect(() => {
|
||||||
|
if (target?.type === 'behavior-tree-node') {
|
||||||
|
const updatedNode = nodes.find(n => n.id === target.data.id);
|
||||||
|
if (updatedNode) {
|
||||||
|
const currentDataStr = JSON.stringify(target.data.data);
|
||||||
|
const updatedDataStr = JSON.stringify(updatedNode.data);
|
||||||
|
if (currentDataStr !== updatedDataStr) {
|
||||||
|
setTarget({ type: 'behavior-tree-node', data: updatedNode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEntitySelection = (data: { entity: Entity | null }) => {
|
||||||
|
if (data.entity) {
|
||||||
|
setTarget({ type: 'entity', data: data.entity });
|
||||||
|
} else {
|
||||||
|
setTarget(null);
|
||||||
|
}
|
||||||
|
setComponentVersion(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoteEntitySelection = (data: { entity: any }) => {
|
||||||
|
setTarget({ type: 'remote-entity', data: data.entity });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEntityDetails = (event: Event) => {
|
||||||
|
const customEvent = event as CustomEvent;
|
||||||
|
const details = customEvent.detail;
|
||||||
|
if (target?.type === 'remote-entity') {
|
||||||
|
setTarget({ ...target, details });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBehaviorTreeNodeSelection = (data: { node: BehaviorTreeNode }) => {
|
||||||
|
setTarget({ type: 'behavior-tree-node', data: data.node });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssetFileSelection = async (data: { fileInfo: AssetFileInfo }) => {
|
||||||
|
const fileInfo = data.fileInfo;
|
||||||
|
|
||||||
|
if (fileInfo.isDirectory) {
|
||||||
|
setTarget({ type: 'asset-file', data: fileInfo });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textExtensions = ['txt', 'json', 'md', 'ts', 'tsx', 'js', 'jsx', 'css', 'html', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'log', 'btree', 'ecs'];
|
||||||
|
const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase());
|
||||||
|
|
||||||
|
if (isTextFile) {
|
||||||
|
try {
|
||||||
|
const content = await TauriAPI.readFileContent(fileInfo.path);
|
||||||
|
setTarget({ type: 'asset-file', data: fileInfo, content });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read file content:', error);
|
||||||
|
setTarget({ type: 'asset-file', data: fileInfo });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTarget({ type: 'asset-file', data: fileInfo });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComponentChange = () => {
|
||||||
|
setComponentVersion((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubEntitySelect = messageHub.subscribe('entity:selected', handleEntitySelection);
|
||||||
|
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection);
|
||||||
|
const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleBehaviorTreeNodeSelection);
|
||||||
|
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
|
||||||
|
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
|
||||||
|
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
|
||||||
|
|
||||||
|
window.addEventListener('profiler:entity-details', handleEntityDetails);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubEntitySelect();
|
||||||
|
unsubRemoteSelect();
|
||||||
|
unsubNodeSelect();
|
||||||
|
unsubAssetFileSelect();
|
||||||
|
unsubComponentAdded();
|
||||||
|
unsubComponentRemoved();
|
||||||
|
window.removeEventListener('profiler:entity-details', handleEntityDetails);
|
||||||
|
};
|
||||||
|
}, [messageHub, target]);
|
||||||
|
|
||||||
|
const handleRemoveComponent = (index: number) => {
|
||||||
|
if (target?.type !== 'entity') return;
|
||||||
|
const entity = target.data;
|
||||||
|
const component = entity.components[index];
|
||||||
|
if (component) {
|
||||||
|
entity.removeComponent(component);
|
||||||
|
messageHub.publish('component:removed', { entity, component });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleComponentExpanded = (index: number) => {
|
||||||
|
setExpandedComponents((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(index)) {
|
||||||
|
newSet.delete(index);
|
||||||
|
} else {
|
||||||
|
newSet.add(index);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePropertyChange = (component: any, propertyName: string, value: any) => {
|
||||||
|
if (target?.type !== 'entity') return;
|
||||||
|
const entity = target.data;
|
||||||
|
messageHub.publish('component:property:changed', {
|
||||||
|
entity,
|
||||||
|
component,
|
||||||
|
propertyName,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodePropertyChange = (propertyName: string, value: any) => {
|
||||||
|
if (target?.type !== 'behavior-tree-node') return;
|
||||||
|
const node = target.data;
|
||||||
|
|
||||||
|
nodeOperations.updateNodeData(node.id, {
|
||||||
|
...node.data,
|
||||||
|
[propertyName]: value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyNodeInfo = () => {
|
||||||
|
if (target?.type !== 'behavior-tree-node') return;
|
||||||
|
const node = target.data;
|
||||||
|
|
||||||
|
const childrenInfo = node.children.map((childId, index) => {
|
||||||
|
const childNode = nodes.find(n => n.id === childId);
|
||||||
|
return ` ${index + 1}. ${childNode?.template.displayName || '未知'} (ID: ${childId})`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const incomingConnections = connections.filter(conn => conn.to === node.id);
|
||||||
|
const outgoingConnections = connections.filter(conn => conn.from === node.id);
|
||||||
|
|
||||||
|
const connectionInfo = [
|
||||||
|
incomingConnections.length > 0 ? `输入连接: ${incomingConnections.length}个` : '',
|
||||||
|
...incomingConnections.map(conn => {
|
||||||
|
const fromNode = nodes.find(n => n.id === conn.from);
|
||||||
|
return ` 来自: ${fromNode?.template.displayName || '未知'} (${conn.from})`;
|
||||||
|
}),
|
||||||
|
outgoingConnections.length > 0 ? `输出连接: ${outgoingConnections.length}个` : '',
|
||||||
|
...outgoingConnections.map(conn => {
|
||||||
|
const toNode = nodes.find(n => n.id === conn.to);
|
||||||
|
return ` 到: ${toNode?.template.displayName || '未知'} (${conn.to})`;
|
||||||
|
})
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
|
||||||
|
const nodeInfo = `
|
||||||
|
节点信息
|
||||||
|
========
|
||||||
|
名称: ${node.template.displayName}
|
||||||
|
类型: ${node.template.type}
|
||||||
|
分类: ${node.template.category}
|
||||||
|
类名: ${node.template.className || '无'}
|
||||||
|
节点ID: ${node.id}
|
||||||
|
|
||||||
|
子节点 (${node.children.length}个):
|
||||||
|
${childrenInfo || ' 无'}
|
||||||
|
|
||||||
|
连接信息:
|
||||||
|
${connectionInfo || ' 无连接'}
|
||||||
|
|
||||||
|
属性数据:
|
||||||
|
${JSON.stringify(node.data, null, 2)}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(nodeInfo).then(() => {
|
||||||
|
messageHub.publish('notification:show', {
|
||||||
|
type: 'success',
|
||||||
|
message: '节点信息已复制到剪贴板'
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = nodeInfo;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
messageHub.publish('notification:show', {
|
||||||
|
type: 'success',
|
||||||
|
message: '节点信息已复制到剪贴板'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRemoteProperty = (key: string, value: any) => {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return (
|
||||||
|
<div key={key} className="property-field">
|
||||||
|
<label className="property-label">{key}</label>
|
||||||
|
<span className="property-value-text">null</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return (
|
||||||
|
<div key={key} className="property-field">
|
||||||
|
<label className="property-label">{key}</label>
|
||||||
|
<div className="property-value-object">
|
||||||
|
{Object.entries(value).map(([subKey, subValue]) => (
|
||||||
|
<div key={subKey} className="property-subfield">
|
||||||
|
<span className="property-sublabel">{subKey}:</span>
|
||||||
|
<span className="property-value-text">{String(subValue)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="property-field">
|
||||||
|
<label className="property-label">{key}</label>
|
||||||
|
<span className="property-value-text">{String(value)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes?: number): string => {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (timestamp?: number): string => {
|
||||||
|
if (!timestamp) return '未知';
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAssetFile = (fileInfo: AssetFileInfo, content?: string) => {
|
||||||
|
const IconComponent = fileInfo.isDirectory ? Folder : FileIcon;
|
||||||
|
const iconColor = fileInfo.isDirectory ? '#dcb67a' : '#90caf9';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="entity-inspector">
|
||||||
|
<div className="inspector-header">
|
||||||
|
<IconComponent size={16} style={{ color: iconColor }} />
|
||||||
|
<span className="entity-name">{fileInfo.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inspector-content">
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">文件信息</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">类型</label>
|
||||||
|
<span className="property-value-text">
|
||||||
|
{fileInfo.isDirectory ? '文件夹' : fileInfo.extension ? `.${fileInfo.extension}` : '文件'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{fileInfo.size !== undefined && !fileInfo.isDirectory && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label"><HardDrive size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />大小</label>
|
||||||
|
<span className="property-value-text">{formatFileSize(fileInfo.size)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fileInfo.modified !== undefined && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label"><Clock size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />修改时间</label>
|
||||||
|
<span className="property-value-text">{formatDate(fileInfo.modified)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">路径</label>
|
||||||
|
<span className="property-value-text" style={{
|
||||||
|
fontFamily: 'Consolas, Monaco, monospace',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#666',
|
||||||
|
wordBreak: 'break-all'
|
||||||
|
}}>
|
||||||
|
{fileInfo.path}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{content && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">文件预览</div>
|
||||||
|
<div className="file-preview-content">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!content && !fileInfo.isDirectory && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div style={{
|
||||||
|
padding: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '13px'
|
||||||
|
}}>
|
||||||
|
此文件类型不支持预览
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBehaviorTreeNode = (node: BehaviorTreeNode) => {
|
||||||
|
const IconComponent = node.template.icon ? (ICON_MAP as any)[node.template.icon] : Box;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="entity-inspector">
|
||||||
|
<div className="inspector-header">
|
||||||
|
{IconComponent && <IconComponent size={16} style={{ color: node.template.color || '#999' }} />}
|
||||||
|
<span className="entity-name">{node.template.displayName || '未命名节点'}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyNodeInfo}
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
color: '#999',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
title="复制节点信息"
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
<span>复制信息</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRunning && (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
backgroundColor: 'rgba(255, 152, 0, 0.1)',
|
||||||
|
borderLeft: '3px solid #ff9800',
|
||||||
|
margin: '12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#ff9800',
|
||||||
|
lineHeight: '1.4'
|
||||||
|
}}>
|
||||||
|
<AlertTriangle size={16} style={{ flexShrink: 0 }} />
|
||||||
|
<span>运行时模式:属性修改将在停止后还原</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="inspector-content">
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">基本信息</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">节点类型</label>
|
||||||
|
<span className="property-value-text">{node.template.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">分类</label>
|
||||||
|
<span className="property-value-text">{node.template.category}</span>
|
||||||
|
</div>
|
||||||
|
{node.template.description && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">描述</label>
|
||||||
|
<span className="property-value-text" style={{ color: '#999' }}>{node.template.description}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{node.template.className && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">类名</label>
|
||||||
|
<span className="property-value-text" style={{ fontFamily: 'Consolas, Monaco, monospace', color: '#0e639c' }}>
|
||||||
|
{node.template.className}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.template.properties && node.template.properties.length > 0 && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">属性</div>
|
||||||
|
<BehaviorTreeNodeProperties
|
||||||
|
key={node.id}
|
||||||
|
selectedNode={node}
|
||||||
|
onPropertyChange={handleNodePropertyChange}
|
||||||
|
projectPath={projectPath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.children.length > 0 && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">子节点 ({node.children.length})</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{node.children.map((childId, index) => {
|
||||||
|
const childNode = nodes.find(n => n.id === childId);
|
||||||
|
const ChildIcon = childNode?.template.icon ? (ICON_MAP as any)[childNode.template.icon] : Box;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={childId}
|
||||||
|
className="child-node-item"
|
||||||
|
style={{
|
||||||
|
borderLeft: `3px solid ${childNode?.template.color || '#666'}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="child-node-index">{index + 1}.</span>
|
||||||
|
{childNode && ChildIcon && (
|
||||||
|
<ChildIcon size={14} style={{ color: childNode.template.color || '#999', flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
<span className="child-node-name">
|
||||||
|
{childNode?.template.displayName || childId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">调试信息</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">节点ID</label>
|
||||||
|
<span className="property-value-text" style={{ fontFamily: 'Consolas, Monaco, monospace', color: '#666', fontSize: '11px' }}>
|
||||||
|
{node.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">位置</label>
|
||||||
|
<span className="property-value-text" style={{ color: '#999' }}>
|
||||||
|
({node.position.x.toFixed(0)}, {node.position.y.toFixed(0)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return (
|
||||||
|
<div className="entity-inspector">
|
||||||
|
<div className="empty-inspector">
|
||||||
|
<FileSearch size={48} style={{ color: '#555', marginBottom: '16px' }} />
|
||||||
|
<div style={{ color: '#999', fontSize: '14px' }}>未选择对象</div>
|
||||||
|
<div style={{ color: '#666', fontSize: '12px', marginTop: '8px' }}>
|
||||||
|
选择实体或节点以查看详细信息
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.type === 'behavior-tree-node') {
|
||||||
|
return renderBehaviorTreeNode(target.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.type === 'asset-file') {
|
||||||
|
return renderAssetFile(target.data, target.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.type === 'remote-entity') {
|
||||||
|
const entity = target.data;
|
||||||
|
const details = (target as any).details;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="entity-inspector">
|
||||||
|
<div className="inspector-header">
|
||||||
|
<Settings size={16} />
|
||||||
|
<span className="entity-name">运行时实体 #{entity.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inspector-content">
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">基本信息</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Entity ID</label>
|
||||||
|
<span className="property-value-text">{entity.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Enabled</label>
|
||||||
|
<span className="property-value-text">{entity.enabled ? 'true' : 'false'}</span>
|
||||||
|
</div>
|
||||||
|
{entity.name && (
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Name</label>
|
||||||
|
<span className="property-value-text">{entity.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{details && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">组件详情</div>
|
||||||
|
{Object.entries(details).map(([key, value]) => renderRemoteProperty(key, value))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.type === 'entity') {
|
||||||
|
const entity = target.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="entity-inspector">
|
||||||
|
<div className="inspector-header">
|
||||||
|
<Settings size={16} />
|
||||||
|
<span className="entity-name">{entity.name || `Entity #${entity.id}`}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inspector-content">
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">基本信息</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Entity ID</label>
|
||||||
|
<span className="property-value-text">{entity.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Enabled</label>
|
||||||
|
<span className="property-value-text">{entity.enabled ? 'true' : 'false'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entity.components.length > 0 && (
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">组件</div>
|
||||||
|
{entity.components.map((component: any, index: number) => {
|
||||||
|
const isExpanded = expandedComponents.has(index);
|
||||||
|
const componentName = component.constructor?.name || 'Component';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${componentName}-${index}-${componentVersion}`} className="component-item">
|
||||||
|
<div className="component-header" onClick={() => toggleComponentExpanded(index)}>
|
||||||
|
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span className="component-name">{componentName}</span>
|
||||||
|
<button
|
||||||
|
className="component-remove-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveComponent(index);
|
||||||
|
}}
|
||||||
|
title="移除组件"
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="component-properties">
|
||||||
|
<PropertyInspector
|
||||||
|
component={component}
|
||||||
|
onChange={(propName: string, value: any) => handlePropertyChange(component, propName, value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
88
packages/editor-app/src/components/PromptDialog.tsx
Normal file
88
packages/editor-app/src/components/PromptDialog.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import '../styles/PromptDialog.css';
|
||||||
|
|
||||||
|
interface PromptDialogProps {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
confirmText: string;
|
||||||
|
cancelText: string;
|
||||||
|
onConfirm: (value: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromptDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
defaultValue = '',
|
||||||
|
placeholder,
|
||||||
|
confirmText,
|
||||||
|
cancelText,
|
||||||
|
onConfirm,
|
||||||
|
onCancel
|
||||||
|
}: PromptDialogProps) {
|
||||||
|
const [value, setValue] = useState(defaultValue);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
inputRef.current.select();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (value.trim()) {
|
||||||
|
onConfirm(value.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleConfirm();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prompt-dialog-overlay" onClick={onCancel}>
|
||||||
|
<div className="prompt-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="prompt-dialog-header">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button className="close-btn" onClick={onCancel}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="prompt-dialog-content">
|
||||||
|
<p>{message}</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className="prompt-dialog-input"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="prompt-dialog-footer">
|
||||||
|
<button className="prompt-dialog-btn cancel" onClick={onCancel}>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="prompt-dialog-btn confirm"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!value.trim()}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
|
import type { Core, ServiceContainer } from '@esengine/ecs-framework';
|
||||||
import { IEditorPlugin, EditorPluginCategory, PanelPosition, MessageHub } from '@esengine/editor-core';
|
import { IEditorPlugin, EditorPluginCategory, PanelPosition, MessageHub } from '@esengine/editor-core';
|
||||||
import type { MenuItem, ToolbarItem, PanelDescriptor, ISerializer } from '@esengine/editor-core';
|
import type { MenuItem, ToolbarItem, PanelDescriptor, ISerializer, FileActionHandler, FileCreationTemplate, FileContextMenuItem } from '@esengine/editor-core';
|
||||||
import { BehaviorTreeData } from '@esengine/behavior-tree';
|
import { BehaviorTreeData } from '@esengine/behavior-tree';
|
||||||
|
import { BehaviorTreeEditorPanel } from '../presentation/components/behavior-tree/panels';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
|
import { TauriAPI } from '../api/tauri';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { useBehaviorTreeStore } from '../stores/behaviorTreeStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 行为树编辑器插件
|
* 行为树编辑器插件
|
||||||
@@ -32,79 +37,24 @@ export class BehaviorTreePlugin implements IEditorPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerMenuItems(): MenuItem[] {
|
registerMenuItems(): MenuItem[] {
|
||||||
return [
|
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[] {
|
registerToolbar(): ToolbarItem[] {
|
||||||
return [
|
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[] {
|
registerPanels(): PanelDescriptor[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'panel-behavior-tree-editor',
|
id: 'behavior-tree-editor',
|
||||||
title: 'Behavior Tree Editor',
|
title: '行为树编辑器',
|
||||||
position: PanelPosition.Center,
|
|
||||||
resizable: true,
|
|
||||||
closable: true,
|
|
||||||
icon: 'Network',
|
icon: 'Network',
|
||||||
order: 10
|
component: BehaviorTreeEditorPanel,
|
||||||
},
|
position: PanelPosition.Center,
|
||||||
{
|
defaultSize: 400,
|
||||||
id: 'panel-behavior-tree-nodes',
|
|
||||||
title: 'Behavior Tree Nodes',
|
|
||||||
position: PanelPosition.Left,
|
|
||||||
defaultSize: 250,
|
|
||||||
resizable: true,
|
|
||||||
closable: true,
|
closable: true,
|
||||||
icon: 'Package',
|
isDynamic: true
|
||||||
order: 20
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'panel-behavior-tree-properties',
|
|
||||||
title: 'Node Properties',
|
|
||||||
position: PanelPosition.Right,
|
|
||||||
defaultSize: 300,
|
|
||||||
resizable: true,
|
|
||||||
closable: true,
|
|
||||||
icon: 'Settings',
|
|
||||||
order: 20
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -155,20 +105,85 @@ export class BehaviorTreePlugin implements IEditorPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private createNewBehaviorTree(): void {
|
registerFileActionHandlers(): FileActionHandler[] {
|
||||||
console.log('[BehaviorTreePlugin] Creating new behavior tree');
|
return [
|
||||||
|
{
|
||||||
|
extensions: ['btree'],
|
||||||
|
onDoubleClick: async (filePath: string) => {
|
||||||
|
console.log('[BehaviorTreePlugin] onDoubleClick called for:', filePath);
|
||||||
|
|
||||||
|
if (this.messageHub) {
|
||||||
|
useBehaviorTreeStore.getState().setIsOpen(true);
|
||||||
|
|
||||||
|
await this.messageHub.publish('dynamic-panel:open', {
|
||||||
|
panelId: 'behavior-tree-editor'
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.messageHub.publish('behavior-tree:open-file', {
|
||||||
|
filePath: filePath
|
||||||
|
});
|
||||||
|
console.log('[BehaviorTreePlugin] Panel opened and file loaded');
|
||||||
|
} else {
|
||||||
|
console.error('[BehaviorTreePlugin] MessageHub is not available!');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpen: async (filePath: string) => {
|
||||||
|
if (this.messageHub) {
|
||||||
|
useBehaviorTreeStore.getState().setIsOpen(true);
|
||||||
|
|
||||||
|
await this.messageHub.publish('dynamic-panel:open', {
|
||||||
|
panelId: 'behavior-tree-editor'
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.messageHub.publish('behavior-tree:open-file', {
|
||||||
|
filePath: filePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getContextMenuItems: (filePath: string, parentPath: string): FileContextMenuItem[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: '打开行为树编辑器',
|
||||||
|
icon: createElement(FileText, { size: 16 }),
|
||||||
|
onClick: async (filePath: string) => {
|
||||||
|
if (this.messageHub) {
|
||||||
|
useBehaviorTreeStore.getState().setIsOpen(true);
|
||||||
|
|
||||||
|
await this.messageHub.publish('dynamic-panel:open', {
|
||||||
|
panelId: 'behavior-tree-editor'
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.messageHub.publish('behavior-tree:open-file', {
|
||||||
|
filePath: filePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private openBehaviorTree(): void {
|
registerFileCreationTemplates(): FileCreationTemplate[] {
|
||||||
console.log('[BehaviorTreePlugin] Opening behavior tree');
|
return [
|
||||||
|
{
|
||||||
|
label: '行为树',
|
||||||
|
extension: 'btree',
|
||||||
|
defaultFileName: 'NewBehaviorTree',
|
||||||
|
icon: createElement(FileText, { size: 16 }),
|
||||||
|
createContent: async (fileName: string) => {
|
||||||
|
const emptyTree: BehaviorTreeData = {
|
||||||
|
id: `tree_${Date.now()}`,
|
||||||
|
name: fileName,
|
||||||
|
rootNodeId: '',
|
||||||
|
nodes: new Map(),
|
||||||
|
blackboardVariables: new Map()
|
||||||
|
};
|
||||||
|
return this.serializeBehaviorTreeData(emptyTree);
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveBehaviorTree(): void {
|
|
||||||
console.log('[BehaviorTreePlugin] Saving behavior tree');
|
|
||||||
}
|
}
|
||||||
|
];
|
||||||
private validateBehaviorTree(): void {
|
|
||||||
console.log('[BehaviorTreePlugin] Validating behavior tree');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializeBehaviorTreeData(treeData: BehaviorTreeData): string {
|
private serializeBehaviorTreeData(treeData: BehaviorTreeData): string {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
|
|||||||
const color = connection.connectionType === 'property' ? '#9c27b0' : '#0e639c';
|
const color = connection.connectionType === 'property' ? '#9c27b0' : '#0e639c';
|
||||||
const strokeColor = isSelected ? '#FFD700' : color;
|
const strokeColor = isSelected ? '#FFD700' : color;
|
||||||
const strokeWidth = isSelected ? 4 : 2;
|
const strokeWidth = isSelected ? 4 : 2;
|
||||||
|
const markerId = `arrowhead-${connection.from}-${connection.to}`;
|
||||||
|
|
||||||
if (!pathData) {
|
if (!pathData) {
|
||||||
// DOM还没渲染完成,跳过此连接
|
// DOM还没渲染完成,跳过此连接
|
||||||
@@ -130,20 +131,10 @@ export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
|
|||||||
strokeWidth={20}
|
strokeWidth={20}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 实际显示的线条 */}
|
{/* 箭头标记定义 */}
|
||||||
<path
|
|
||||||
d={pathData.path}
|
|
||||||
fill="none"
|
|
||||||
stroke={strokeColor}
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
strokeLinecap="round"
|
|
||||||
markerEnd="url(#arrowhead)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 箭头标记 */}
|
|
||||||
<defs>
|
<defs>
|
||||||
<marker
|
<marker
|
||||||
id="arrowhead"
|
id={markerId}
|
||||||
markerWidth="10"
|
markerWidth="10"
|
||||||
markerHeight="10"
|
markerHeight="10"
|
||||||
refX="9"
|
refX="9"
|
||||||
@@ -158,6 +149,16 @@ export const ConnectionRenderer: React.FC<ConnectionRendererProps> = ({
|
|||||||
</marker>
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
|
{/* 实际显示的线条 */}
|
||||||
|
<path
|
||||||
|
d={pathData.path}
|
||||||
|
fill="none"
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
markerEnd={`url(#${markerId})`}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 选中时显示的中点 */}
|
{/* 选中时显示的中点 */}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<circle
|
<circle
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
LucideIcon
|
LucideIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PropertyDefinition } from '@esengine/behavior-tree';
|
import { PropertyDefinition } from '@esengine/behavior-tree';
|
||||||
import { BehaviorTreeNode as BehaviorTreeNodeType, Connection, ROOT_NODE_ID } from '../../../../stores/behaviorTreeStore';
|
import { BehaviorTreeNode as BehaviorTreeNodeType, Connection, ROOT_NODE_ID, NodeExecutionStatus } from '../../../../stores/behaviorTreeStore';
|
||||||
import { BehaviorTreeExecutor } from '../../../../utils/BehaviorTreeExecutor';
|
import { BehaviorTreeExecutor } from '../../../../utils/BehaviorTreeExecutor';
|
||||||
import { BlackboardValue } from '../../../../domain/models/Blackboard';
|
import { BlackboardValue } from '../../../../domain/models/Blackboard';
|
||||||
|
|
||||||
@@ -22,6 +22,8 @@ interface BehaviorTreeNodeProps {
|
|||||||
blackboardVariables: BlackboardVariables;
|
blackboardVariables: BlackboardVariables;
|
||||||
initialBlackboardVariables: BlackboardVariables;
|
initialBlackboardVariables: BlackboardVariables;
|
||||||
isExecuting: boolean;
|
isExecuting: boolean;
|
||||||
|
executionStatus?: NodeExecutionStatus;
|
||||||
|
executionOrder?: number;
|
||||||
connections: Connection[];
|
connections: Connection[];
|
||||||
nodes: BehaviorTreeNodeType[];
|
nodes: BehaviorTreeNodeType[];
|
||||||
executorRef: React.RefObject<BehaviorTreeExecutor | null>;
|
executorRef: React.RefObject<BehaviorTreeExecutor | null>;
|
||||||
@@ -44,6 +46,8 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
|||||||
blackboardVariables,
|
blackboardVariables,
|
||||||
initialBlackboardVariables,
|
initialBlackboardVariables,
|
||||||
isExecuting,
|
isExecuting,
|
||||||
|
executionStatus,
|
||||||
|
executionOrder,
|
||||||
connections,
|
connections,
|
||||||
nodes,
|
nodes,
|
||||||
executorRef,
|
executorRef,
|
||||||
@@ -67,7 +71,8 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
|||||||
'bt-node',
|
'bt-node',
|
||||||
isSelected && 'selected',
|
isSelected && 'selected',
|
||||||
isRoot && 'root',
|
isRoot && 'root',
|
||||||
isUncommitted && 'uncommitted'
|
isUncommitted && 'uncommitted',
|
||||||
|
executionStatus && executionStatus !== 'idle' && executionStatus
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -162,11 +167,33 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
|||||||
#{node.id}
|
#{node.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{executionOrder !== undefined && (
|
||||||
|
<div
|
||||||
|
className="bt-node-execution-order"
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
backgroundColor: '#2196f3',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
title={`执行顺序: ${executionOrder}`}
|
||||||
|
>
|
||||||
|
{executionOrder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
|
{!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
|
||||||
<div
|
<div
|
||||||
className="bt-node-missing-executor-warning"
|
className="bt-node-missing-executor-warning"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: 'auto',
|
marginLeft: executionOrder !== undefined ? '4px' : 'auto',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
cursor: 'help',
|
cursor: 'help',
|
||||||
@@ -191,7 +218,7 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
|||||||
<div
|
<div
|
||||||
className="bt-node-uncommitted-warning"
|
className="bt-node-uncommitted-warning"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: !isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) ? '4px' : 'auto',
|
marginLeft: (executionOrder !== undefined || (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className))) ? '4px' : 'auto',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
cursor: 'help',
|
cursor: 'help',
|
||||||
@@ -220,7 +247,7 @@ export const BehaviorTreeNode: React.FC<BehaviorTreeNodeProps> = ({
|
|||||||
<div
|
<div
|
||||||
className="bt-node-empty-warning-container"
|
className="bt-node-empty-warning-container"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: isUncommitted ? '4px' : 'auto',
|
marginLeft: (executionOrder !== undefined || (!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className)) || isUncommitted) ? '4px' : 'auto',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
cursor: 'help',
|
cursor: 'help',
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
.behavior-tree-editor-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具栏 */
|
||||||
|
.behavior-tree-editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-btn:active:not(:disabled) {
|
||||||
|
background: var(--bg-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 执行控制按钮颜色 */
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-btn.btn-play {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-btn.btn-play:hover:not(:disabled) {
|
||||||
|
background: rgba(76, 175, 80, 0.15);
|
||||||
|
border-color: rgba(76, 175, 80, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-btn.btn-pause {
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-btn.btn-pause:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 152, 0, 0.15);
|
||||||
|
border-color: rgba(255, 152, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-btn.btn-stop {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-btn.btn-stop:hover:not(:disabled) {
|
||||||
|
background: rgba(244, 67, 54, 0.15);
|
||||||
|
border-color: rgba(244, 67, 54, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-btn.btn-step {
|
||||||
|
color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .toolbar-btn.btn-step:hover:not(:disabled) {
|
||||||
|
background: rgba(33, 150, 243, 0.15);
|
||||||
|
border-color: rgba(33, 150, 243, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 速率控制 */
|
||||||
|
.speed-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-control .speed-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-control .speed-slider {
|
||||||
|
width: 100px;
|
||||||
|
height: 6px;
|
||||||
|
background: #3c3c3c;
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-control .speed-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #0e639c;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 0 2px rgba(14, 99, 156, 0.3);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-control .speed-slider::-webkit-slider-thumb:hover {
|
||||||
|
background: #1177bb;
|
||||||
|
box-shadow: 0 0 0 4px rgba(14, 99, 156, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-control .speed-slider::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #0e639c;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(14, 99, 156, 0.3);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-control .speed-slider::-moz-range-thumb:hover {
|
||||||
|
background: #1177bb;
|
||||||
|
box-shadow: 0 0 0 4px rgba(14, 99, 156, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-control .speed-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ccc;
|
||||||
|
min-width: 35px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件信息 */
|
||||||
|
.behavior-tree-editor-toolbar .file-info {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.behavior-tree-editor-toolbar .file-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主内容区 */
|
||||||
|
.behavior-tree-editor-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-canvas-area {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 黑板侧边栏 - 浮动面板 */
|
||||||
|
.blackboard-sidebar {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
top: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
width: 350px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blackboard-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blackboard-header .close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blackboard-header .close-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blackboard-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 黑板切换按钮 */
|
||||||
|
.blackboard-toggle-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 48px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
z-index: 99;
|
||||||
|
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blackboard-toggle-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
width: 36px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,743 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Save, FolderOpen, Download, Play, Pause, Square, SkipForward, Clipboard, ChevronRight, ChevronLeft, Copy } from 'lucide-react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { open, message } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { Core } from '@esengine/ecs-framework';
|
||||||
|
import { MessageHub } from '@esengine/editor-core';
|
||||||
|
import { BehaviorTreeEditor } from '../../../../components/BehaviorTreeEditor';
|
||||||
|
import { BehaviorTreeBlackboard } from '../../../../components/BehaviorTreeBlackboard';
|
||||||
|
import { ExportRuntimeDialog, type ExportOptions } from '../../../../components/ExportRuntimeDialog';
|
||||||
|
import { BehaviorTreeNameDialog } from '../../../../components/BehaviorTreeNameDialog';
|
||||||
|
import { useToast } from '../../../../components/Toast';
|
||||||
|
import { useBehaviorTreeStore, ROOT_NODE_ID } from '../../../../stores/behaviorTreeStore';
|
||||||
|
import { EditorFormatConverter, BehaviorTreeAssetSerializer, GlobalBlackboardService, type BlackboardValueType } from '@esengine/behavior-tree';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
import { LocalBlackboardTypeGenerator } from '../../../../generators/LocalBlackboardTypeGenerator';
|
||||||
|
import { GlobalBlackboardTypeGenerator } from '../../../../generators/GlobalBlackboardTypeGenerator';
|
||||||
|
import { useExecutionController } from '../../../hooks/useExecutionController';
|
||||||
|
import './BehaviorTreeEditorPanel.css';
|
||||||
|
|
||||||
|
const logger = createLogger('BehaviorTreeEditorPanel');
|
||||||
|
|
||||||
|
interface BehaviorTreeEditorPanelProps {
|
||||||
|
projectPath?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BehaviorTreeEditorPanel: React.FC<BehaviorTreeEditorPanelProps> = ({ projectPath: propProjectPath }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const messageHub = Core.services.resolve(MessageHub);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
nodes,
|
||||||
|
connections,
|
||||||
|
exportToJSON,
|
||||||
|
exportToRuntimeAsset,
|
||||||
|
importFromJSON,
|
||||||
|
blackboardVariables,
|
||||||
|
setBlackboardVariables,
|
||||||
|
updateBlackboardVariable,
|
||||||
|
initialBlackboardVariables,
|
||||||
|
setInitialBlackboardVariables,
|
||||||
|
isExecuting,
|
||||||
|
setIsExecuting,
|
||||||
|
saveNodesDataSnapshot,
|
||||||
|
restoreNodesData,
|
||||||
|
setIsOpen,
|
||||||
|
reset
|
||||||
|
} = useBehaviorTreeStore();
|
||||||
|
|
||||||
|
const [currentFilePath, setCurrentFilePath] = useState<string | null>(null);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
const [projectPath, setProjectPath] = useState<string>('');
|
||||||
|
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||||
|
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||||
|
const [availableBTreeFiles, setAvailableBTreeFiles] = useState<string[]>([]);
|
||||||
|
const [isBlackboardOpen, setIsBlackboardOpen] = useState(true);
|
||||||
|
const [globalVariables, setGlobalVariables] = useState<Record<string, any>>({});
|
||||||
|
const [hasUnsavedGlobalChanges, setHasUnsavedGlobalChanges] = useState(false);
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
const initialStateSnapshot = useRef<{ nodes: number; variables: number }>({ nodes: 0, variables: 0 });
|
||||||
|
|
||||||
|
const {
|
||||||
|
executionMode,
|
||||||
|
executionSpeed,
|
||||||
|
handlePlay,
|
||||||
|
handlePause,
|
||||||
|
handleStop,
|
||||||
|
handleStep,
|
||||||
|
handleSpeedChange
|
||||||
|
} = useExecutionController({
|
||||||
|
rootNodeId: ROOT_NODE_ID,
|
||||||
|
projectPath: projectPath || '',
|
||||||
|
blackboardVariables,
|
||||||
|
nodes,
|
||||||
|
connections,
|
||||||
|
initialBlackboardVariables,
|
||||||
|
onBlackboardUpdate: setBlackboardVariables,
|
||||||
|
onInitialBlackboardSave: setInitialBlackboardVariables,
|
||||||
|
onExecutingChange: setIsExecuting,
|
||||||
|
onSaveNodesDataSnapshot: saveNodesDataSnapshot,
|
||||||
|
onRestoreNodesData: restoreNodesData
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initProject = async () => {
|
||||||
|
if (propProjectPath) {
|
||||||
|
setProjectPath(propProjectPath);
|
||||||
|
localStorage.setItem('ecs-project-path', propProjectPath);
|
||||||
|
await loadGlobalBlackboard(propProjectPath);
|
||||||
|
} else {
|
||||||
|
const savedPath = localStorage.getItem('ecs-project-path');
|
||||||
|
if (savedPath) {
|
||||||
|
setProjectPath(savedPath);
|
||||||
|
await loadGlobalBlackboard(savedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initProject();
|
||||||
|
}, [propProjectPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAvailableFiles = async () => {
|
||||||
|
if (projectPath) {
|
||||||
|
try {
|
||||||
|
const files = await invoke<string[]>('scan_behavior_trees', { projectPath });
|
||||||
|
setAvailableBTreeFiles(files);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('加载行为树文件列表失败', error);
|
||||||
|
setAvailableBTreeFiles([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAvailableBTreeFiles([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAvailableFiles();
|
||||||
|
}, [projectPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateGlobalVariables = () => {
|
||||||
|
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||||
|
const allVars = globalBlackboard.getAllVariables();
|
||||||
|
const varsObject: Record<string, any> = {};
|
||||||
|
allVars.forEach((v) => {
|
||||||
|
varsObject[v.name] = v.value;
|
||||||
|
});
|
||||||
|
setGlobalVariables(varsObject);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateGlobalVariables();
|
||||||
|
const interval = setInterval(updateGlobalVariables, 100);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentNodes = nodes.length;
|
||||||
|
const currentVariables = Object.keys(blackboardVariables).length;
|
||||||
|
|
||||||
|
if (currentNodes !== initialStateSnapshot.current.nodes ||
|
||||||
|
currentVariables !== initialStateSnapshot.current.variables) {
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
}
|
||||||
|
}, [nodes, blackboardVariables]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOpenFile = async (data: any) => {
|
||||||
|
if (data.filePath && data.filePath.endsWith('.btree')) {
|
||||||
|
try {
|
||||||
|
const json = await invoke<string>('read_behavior_tree_file', { filePath: data.filePath });
|
||||||
|
importFromJSON(json);
|
||||||
|
setCurrentFilePath(data.filePath);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
isInitialMount.current = true;
|
||||||
|
initialStateSnapshot.current = {
|
||||||
|
nodes: nodes.length,
|
||||||
|
variables: Object.keys(blackboardVariables).length
|
||||||
|
};
|
||||||
|
logger.info('行为树已加载', data.filePath);
|
||||||
|
showToast(`已打开 ${data.filePath.split(/[\\/]/).pop()?.replace('.btree', '')}`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('加载行为树失败', error);
|
||||||
|
showToast(`加载失败: ${error}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = messageHub?.subscribe('behavior-tree:open-file', handleOpenFile);
|
||||||
|
return () => unsubscribe?.();
|
||||||
|
}, [messageHub, importFromJSON, nodes.length, blackboardVariables]);
|
||||||
|
|
||||||
|
const loadGlobalBlackboard = async (path: string) => {
|
||||||
|
try {
|
||||||
|
const json = await invoke<string>('read_global_blackboard', { projectPath: path });
|
||||||
|
const config = JSON.parse(json);
|
||||||
|
Core.services.resolve(GlobalBlackboardService).importConfig(config);
|
||||||
|
|
||||||
|
const allVars = Core.services.resolve(GlobalBlackboardService).getAllVariables();
|
||||||
|
const varsObject: Record<string, any> = {};
|
||||||
|
allVars.forEach((v) => {
|
||||||
|
varsObject[v.name] = v.value;
|
||||||
|
});
|
||||||
|
setGlobalVariables(varsObject);
|
||||||
|
setHasUnsavedGlobalChanges(false);
|
||||||
|
logger.info('全局黑板配置已加载');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('加载全局黑板配置失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveGlobalBlackboard = async () => {
|
||||||
|
if (!projectPath) {
|
||||||
|
logger.error('未设置项目路径,无法保存全局黑板配置');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||||
|
const json = globalBlackboard.toJSON();
|
||||||
|
await invoke('write_global_blackboard', { projectPath, content: json });
|
||||||
|
setHasUnsavedGlobalChanges(false);
|
||||||
|
logger.info('全局黑板配置已保存到', `${projectPath}/.ecs/global-blackboard.json`);
|
||||||
|
showToast('全局黑板已保存', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('保存全局黑板配置失败', error);
|
||||||
|
showToast('保存全局黑板失败', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = async () => {
|
||||||
|
try {
|
||||||
|
const selected = await open({
|
||||||
|
multiple: false,
|
||||||
|
filters: [{
|
||||||
|
name: 'Behavior Tree',
|
||||||
|
extensions: ['btree']
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
const json = await invoke<string>('read_behavior_tree_file', { filePath: selected as string });
|
||||||
|
importFromJSON(json);
|
||||||
|
setCurrentFilePath(selected as string);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
isInitialMount.current = true;
|
||||||
|
logger.info('行为树已加载', selected);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('加载失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
if (isExecuting) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'行为树正在运行中。保存将使用设计时的初始值,运行时修改的黑板变量不会被保存。\n\n是否继续保存?'
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveFilePath = currentFilePath;
|
||||||
|
|
||||||
|
if (!saveFilePath) {
|
||||||
|
if (!projectPath) {
|
||||||
|
logger.error('未设置项目路径,无法保存行为树');
|
||||||
|
await message('请先打开项目', { title: '错误', kind: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaveDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveToFile(saveFilePath);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('保存失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveToFile = async (filePath: string) => {
|
||||||
|
try {
|
||||||
|
const json = exportToJSON({ name: 'behavior-tree', description: '' });
|
||||||
|
await invoke('write_behavior_tree_file', { filePath, content: json });
|
||||||
|
logger.info('行为树已保存', filePath);
|
||||||
|
|
||||||
|
setCurrentFilePath(filePath);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
isInitialMount.current = true;
|
||||||
|
|
||||||
|
const fileName = filePath.split(/[\\/]/).pop()?.replace('.btree', '') || '行为树';
|
||||||
|
showToast(`${fileName} 已保存`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('保存失败', error);
|
||||||
|
showToast(`保存失败: ${error}`, 'error');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDialogConfirm = async (name: string) => {
|
||||||
|
setIsSaveDialogOpen(false);
|
||||||
|
try {
|
||||||
|
const filePath = `${projectPath}/.ecs/behaviors/${name}.btree`;
|
||||||
|
await saveToFile(filePath);
|
||||||
|
|
||||||
|
const files = await invoke<string[]>('scan_behavior_trees', { projectPath });
|
||||||
|
setAvailableBTreeFiles(files);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('保存失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportRuntime = () => {
|
||||||
|
setIsExportDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDoExport = async (options: ExportOptions) => {
|
||||||
|
if (options.mode === 'workspace') {
|
||||||
|
await handleExportWorkspace(options);
|
||||||
|
} else {
|
||||||
|
const fileName = options.selectedFiles[0];
|
||||||
|
if (!fileName) {
|
||||||
|
logger.error('没有可导出的文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const format = options.fileFormats.get(fileName) || 'binary';
|
||||||
|
await handleExportSingle(fileName, format, options.assetOutputPath, options.typeOutputPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportSingle = async (fileName: string, format: 'json' | 'binary', outputPath: string, typeOutputPath: string) => {
|
||||||
|
try {
|
||||||
|
const extension = format === 'binary' ? 'bin' : 'json';
|
||||||
|
const filePath = `${outputPath}/${fileName}.btree.${extension}`;
|
||||||
|
|
||||||
|
const data = exportToRuntimeAsset(
|
||||||
|
{ name: fileName, description: 'Runtime behavior tree asset' },
|
||||||
|
format
|
||||||
|
);
|
||||||
|
|
||||||
|
await invoke('create_directory', { path: outputPath });
|
||||||
|
|
||||||
|
if (format === 'binary') {
|
||||||
|
await invoke('write_binary_file', { filePath, content: Array.from(data as Uint8Array) });
|
||||||
|
} else {
|
||||||
|
await invoke('write_file_content', { path: filePath, content: data as string });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`运行时资产已导出 (${format})`, filePath);
|
||||||
|
|
||||||
|
await generateTypeScriptTypes(fileName, typeOutputPath);
|
||||||
|
|
||||||
|
showToast(`${fileName} 导出成功`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('导出失败', error);
|
||||||
|
showToast(`导出失败: ${error}`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateTypeScriptTypes = async (assetId: string, outputPath: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const sourceFilePath = `${projectPath}/.ecs/behaviors/${assetId}.btree`;
|
||||||
|
const editorJson = await invoke<string>('read_file_content', { path: sourceFilePath });
|
||||||
|
|
||||||
|
const editorFormat = JSON.parse(editorJson);
|
||||||
|
const blackboard = editorFormat.blackboard || {};
|
||||||
|
|
||||||
|
const tsCode = LocalBlackboardTypeGenerator.generate(blackboard, {
|
||||||
|
behaviorTreeName: assetId,
|
||||||
|
includeConstants: true,
|
||||||
|
includeDefaults: true,
|
||||||
|
includeHelpers: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const tsFilePath = `${outputPath}/${assetId}.ts`;
|
||||||
|
await invoke('create_directory', { path: outputPath });
|
||||||
|
await invoke('write_file_content', {
|
||||||
|
path: tsFilePath,
|
||||||
|
content: tsCode
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`TypeScript 类型定义已生成: ${assetId}.ts`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`生成 TypeScript 类型定义失败: ${assetId}`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyBehaviorTree = () => {
|
||||||
|
const buildNodeTree = (nodeId: string, depth: number = 0): string => {
|
||||||
|
const node = nodes.find(n => n.id === nodeId);
|
||||||
|
if (!node) return '';
|
||||||
|
|
||||||
|
const indent = ' '.repeat(depth);
|
||||||
|
const childrenText = node.children.length > 0
|
||||||
|
? `\n${node.children.map(childId => buildNodeTree(childId, depth + 1)).join('\n')}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const propertiesText = Object.keys(node.data).length > 0
|
||||||
|
? ` [${Object.entries(node.data).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(', ')}]`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `${indent}- ${node.template.displayName} (${node.template.type})${propertiesText}${childrenText}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootNode = nodes.find(n => n.id === ROOT_NODE_ID);
|
||||||
|
if (!rootNode) {
|
||||||
|
showToast('未找到根节点', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeStructure = `
|
||||||
|
行为树结构
|
||||||
|
==========
|
||||||
|
文件: ${currentFilePath || '未保存'}
|
||||||
|
节点总数: ${nodes.length}
|
||||||
|
连接总数: ${connections.length}
|
||||||
|
|
||||||
|
节点树:
|
||||||
|
${buildNodeTree(ROOT_NODE_ID)}
|
||||||
|
|
||||||
|
黑板变量 (${Object.keys(blackboardVariables).length}个):
|
||||||
|
${Object.entries(blackboardVariables).map(([key, value]) => ` - ${key}: ${JSON.stringify(value)}`).join('\n') || ' 无'}
|
||||||
|
|
||||||
|
全部节点详情:
|
||||||
|
${nodes.filter(n => n.id !== ROOT_NODE_ID).map(node => {
|
||||||
|
const incoming = connections.filter(c => c.to === node.id);
|
||||||
|
const outgoing = connections.filter(c => c.from === node.id);
|
||||||
|
return `
|
||||||
|
[${node.template.displayName}]
|
||||||
|
类型: ${node.template.type}
|
||||||
|
分类: ${node.template.category}
|
||||||
|
类名: ${node.template.className || '无'}
|
||||||
|
ID: ${node.id}
|
||||||
|
子节点: ${node.children.length}个
|
||||||
|
输入连接: ${incoming.length}个${incoming.length > 0 ? '\n ' + incoming.map(c => {
|
||||||
|
const fromNode = nodes.find(n => n.id === c.from);
|
||||||
|
return `← ${fromNode?.template.displayName || '未知'}`;
|
||||||
|
}).join('\n ') : ''}
|
||||||
|
输出连接: ${outgoing.length}个${outgoing.length > 0 ? '\n ' + outgoing.map(c => {
|
||||||
|
const toNode = nodes.find(n => n.id === c.to);
|
||||||
|
return `→ ${toNode?.template.displayName || '未知'}`;
|
||||||
|
}).join('\n ') : ''}
|
||||||
|
属性: ${JSON.stringify(node.data, null, 4)}`;
|
||||||
|
}).join('\n')}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(treeStructure).then(() => {
|
||||||
|
showToast('行为树结构已复制到剪贴板', 'success');
|
||||||
|
}).catch(() => {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = treeStructure;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
showToast('行为树结构已复制到剪贴板', 'success');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportWorkspace = async (options: ExportOptions) => {
|
||||||
|
if (!projectPath) {
|
||||||
|
logger.error('未设置项目路径');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const assetOutputDir = options.assetOutputPath;
|
||||||
|
|
||||||
|
if (options.selectedFiles.length === 0) {
|
||||||
|
logger.warn('没有选择要导出的文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`开始导出 ${options.selectedFiles.length} 个文件...`);
|
||||||
|
|
||||||
|
for (const assetId of options.selectedFiles) {
|
||||||
|
try {
|
||||||
|
const format = options.fileFormats.get(assetId) || 'binary';
|
||||||
|
const extension = format === 'binary' ? 'bin' : 'json';
|
||||||
|
|
||||||
|
const sourceFilePath = `${projectPath}/.ecs/behaviors/${assetId}.btree`;
|
||||||
|
const editorJson = await invoke<string>('read_file_content', { path: sourceFilePath });
|
||||||
|
|
||||||
|
const editorFormat = JSON.parse(editorJson);
|
||||||
|
|
||||||
|
const asset = EditorFormatConverter.toAsset(editorFormat, {
|
||||||
|
name: assetId,
|
||||||
|
description: editorFormat.metadata?.description || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = BehaviorTreeAssetSerializer.serialize(asset, {
|
||||||
|
format,
|
||||||
|
pretty: format === 'json',
|
||||||
|
validate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const outputFilePath = `${assetOutputDir}/${assetId}.btree.${extension}`;
|
||||||
|
|
||||||
|
const outputDir2 = outputFilePath.substring(0, outputFilePath.lastIndexOf('/'));
|
||||||
|
await invoke('create_directory', { path: outputDir2 });
|
||||||
|
|
||||||
|
if (format === 'binary') {
|
||||||
|
await invoke('write_binary_file', {
|
||||||
|
filePath: outputFilePath,
|
||||||
|
content: Array.from(data as Uint8Array)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await invoke('write_file_content', {
|
||||||
|
path: outputFilePath,
|
||||||
|
content: data as string
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`导出成功: ${assetId} (${format})`);
|
||||||
|
|
||||||
|
await generateTypeScriptTypes(assetId, options.typeOutputPath);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`导出失败: ${assetId}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||||
|
const config = globalBlackboard.exportConfig();
|
||||||
|
const tsCode = GlobalBlackboardTypeGenerator.generate(config);
|
||||||
|
const globalTsFilePath = `${options.typeOutputPath}/GlobalBlackboard.ts`;
|
||||||
|
|
||||||
|
await invoke('write_file_content', {
|
||||||
|
path: globalTsFilePath,
|
||||||
|
content: tsCode
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('全局变量类型定义已生成:', globalTsFilePath);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('导出全局变量类型定义失败', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`工作区导出完成: ${assetOutputDir}`);
|
||||||
|
showToast('工作区导出成功', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('工作区导出失败', error);
|
||||||
|
showToast(`工作区导出失败: ${error}`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVariableChange = (key: string, value: any) => {
|
||||||
|
updateBlackboardVariable(key, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVariableAdd = (key: string, value: any) => {
|
||||||
|
updateBlackboardVariable(key, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVariableDelete = (key: string) => {
|
||||||
|
const newVars = { ...blackboardVariables };
|
||||||
|
delete newVars[key];
|
||||||
|
setBlackboardVariables(newVars);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVariableRename = (oldKey: string, newKey: string) => {
|
||||||
|
if (oldKey === newKey) return;
|
||||||
|
const newVars = { ...blackboardVariables };
|
||||||
|
const value = newVars[oldKey];
|
||||||
|
delete newVars[oldKey];
|
||||||
|
newVars[newKey] = value;
|
||||||
|
setBlackboardVariables(newVars);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGlobalVariableChange = (key: string, value: any) => {
|
||||||
|
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||||
|
globalBlackboard.setValue(key, value, true);
|
||||||
|
const allVars = globalBlackboard.getAllVariables();
|
||||||
|
const varsObject: Record<string, any> = {};
|
||||||
|
allVars.forEach((v) => {
|
||||||
|
varsObject[v.name] = v.value;
|
||||||
|
});
|
||||||
|
setGlobalVariables(varsObject);
|
||||||
|
setHasUnsavedGlobalChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGlobalVariableAdd = (key: string, value: any, type: BlackboardValueType) => {
|
||||||
|
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||||
|
globalBlackboard.defineVariable(key, type, value);
|
||||||
|
const allVars = globalBlackboard.getAllVariables();
|
||||||
|
const varsObject: Record<string, any> = {};
|
||||||
|
allVars.forEach((v) => {
|
||||||
|
varsObject[v.name] = v.value;
|
||||||
|
});
|
||||||
|
setGlobalVariables(varsObject);
|
||||||
|
setHasUnsavedGlobalChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGlobalVariableDelete = (key: string) => {
|
||||||
|
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||||
|
globalBlackboard.removeVariable(key);
|
||||||
|
const allVars = globalBlackboard.getAllVariables();
|
||||||
|
const varsObject: Record<string, any> = {};
|
||||||
|
allVars.forEach((v) => {
|
||||||
|
varsObject[v.name] = v.value;
|
||||||
|
});
|
||||||
|
setGlobalVariables(varsObject);
|
||||||
|
setHasUnsavedGlobalChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="behavior-tree-editor-panel">
|
||||||
|
<div className="behavior-tree-editor-toolbar">
|
||||||
|
{/* 文件操作 */}
|
||||||
|
<div className="toolbar-section">
|
||||||
|
<button onClick={handleOpen} className="toolbar-btn" title="打开">
|
||||||
|
<FolderOpen size={16} />
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSave} className="toolbar-btn" title="保存">
|
||||||
|
<Save size={16} />
|
||||||
|
</button>
|
||||||
|
<button onClick={handleExportRuntime} className="toolbar-btn" title="导出运行时资产">
|
||||||
|
<Download size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
|
{/* 执行控制 */}
|
||||||
|
<div className="toolbar-section">
|
||||||
|
{executionMode === 'idle' || executionMode === 'step' ? (
|
||||||
|
<button onClick={handlePlay} className="toolbar-btn btn-play" title="开始执行">
|
||||||
|
<Play size={16} />
|
||||||
|
</button>
|
||||||
|
) : executionMode === 'paused' ? (
|
||||||
|
<button onClick={handlePlay} className="toolbar-btn btn-play" title="继续">
|
||||||
|
<Play size={16} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={handlePause} className="toolbar-btn btn-pause" title="暂停">
|
||||||
|
<Pause size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={handleStop} className="toolbar-btn btn-stop" title="停止" disabled={executionMode === 'idle'}>
|
||||||
|
<Square size={16} />
|
||||||
|
</button>
|
||||||
|
<button onClick={handleStep} className="toolbar-btn btn-step" title="单步执行" disabled={executionMode === 'running'}>
|
||||||
|
<SkipForward size={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="speed-control">
|
||||||
|
<span className="speed-label">速率:</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0.1"
|
||||||
|
max="5"
|
||||||
|
step="0.1"
|
||||||
|
value={executionSpeed}
|
||||||
|
onChange={(e) => handleSpeedChange(parseFloat(e.target.value))}
|
||||||
|
className="speed-slider"
|
||||||
|
title={`执行速率: ${executionSpeed.toFixed(1)}x`}
|
||||||
|
/>
|
||||||
|
<span className="speed-value">{executionSpeed.toFixed(1)}x</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar-divider" />
|
||||||
|
|
||||||
|
{/* 视图控制 */}
|
||||||
|
<div className="toolbar-section">
|
||||||
|
<button onClick={() => setIsBlackboardOpen(!isBlackboardOpen)} className="toolbar-btn" title="黑板">
|
||||||
|
<Clipboard size={16} />
|
||||||
|
</button>
|
||||||
|
<button onClick={handleCopyBehaviorTree} className="toolbar-btn" title="复制整个行为树结构">
|
||||||
|
<Copy size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文件名 */}
|
||||||
|
<div className="toolbar-section file-info">
|
||||||
|
<span className="file-name">
|
||||||
|
{currentFilePath
|
||||||
|
? `${currentFilePath.split(/[\\/]/).pop()?.replace('.btree', '')}${hasUnsavedChanges ? ' *' : ''}`
|
||||||
|
: t('behaviorTree.title')
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="behavior-tree-editor-content">
|
||||||
|
<div className="editor-canvas-area">
|
||||||
|
<BehaviorTreeEditor
|
||||||
|
onNodeSelect={(node) => {
|
||||||
|
messageHub?.publish('behavior-tree:node-selected', { node });
|
||||||
|
}}
|
||||||
|
onNodeCreate={(_template, _position) => {
|
||||||
|
// Node created
|
||||||
|
}}
|
||||||
|
blackboardVariables={blackboardVariables}
|
||||||
|
projectPath={projectPath || propProjectPath || null}
|
||||||
|
showToolbar={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isBlackboardOpen && (
|
||||||
|
<div className="blackboard-sidebar">
|
||||||
|
<div className="blackboard-header">
|
||||||
|
<span>{t('behaviorTree.blackboard')}</span>
|
||||||
|
<button onClick={() => setIsBlackboardOpen(false)} className="close-btn">
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="blackboard-content">
|
||||||
|
<BehaviorTreeBlackboard
|
||||||
|
variables={blackboardVariables}
|
||||||
|
initialVariables={isExecuting ? initialBlackboardVariables : undefined}
|
||||||
|
globalVariables={globalVariables}
|
||||||
|
onVariableChange={handleVariableChange}
|
||||||
|
onVariableAdd={handleVariableAdd}
|
||||||
|
onVariableDelete={handleVariableDelete}
|
||||||
|
onVariableRename={handleVariableRename}
|
||||||
|
onGlobalVariableChange={handleGlobalVariableChange}
|
||||||
|
onGlobalVariableAdd={handleGlobalVariableAdd}
|
||||||
|
onGlobalVariableDelete={handleGlobalVariableDelete}
|
||||||
|
projectPath={projectPath}
|
||||||
|
hasUnsavedGlobalChanges={hasUnsavedGlobalChanges}
|
||||||
|
onSaveGlobal={saveGlobalBlackboard}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isBlackboardOpen && (
|
||||||
|
<button onClick={() => setIsBlackboardOpen(true)} className="blackboard-toggle-btn" title="显示黑板">
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExportRuntimeDialog
|
||||||
|
isOpen={isExportDialogOpen}
|
||||||
|
onClose={() => setIsExportDialogOpen(false)}
|
||||||
|
onExport={handleDoExport}
|
||||||
|
hasProject={!!projectPath}
|
||||||
|
availableFiles={availableBTreeFiles}
|
||||||
|
currentFileName={currentFilePath ? currentFilePath.split(/[\\/]/).pop()?.replace('.btree', '') : undefined}
|
||||||
|
projectPath={projectPath}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BehaviorTreeNameDialog
|
||||||
|
isOpen={isSaveDialogOpen}
|
||||||
|
onConfirm={handleSaveDialogConfirm}
|
||||||
|
onCancel={() => setIsSaveDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.behavior-tree-node-palette-panel {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Core } from '@esengine/ecs-framework';
|
||||||
|
import { MessageHub } from '@esengine/editor-core';
|
||||||
|
import { BehaviorTreeNodePalette } from '../../../../components/BehaviorTreeNodePalette';
|
||||||
|
import './BehaviorTreeNodePalettePanel.css';
|
||||||
|
|
||||||
|
export const BehaviorTreeNodePalettePanel: React.FC = () => {
|
||||||
|
const messageHub = Core.services.resolve(MessageHub);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="behavior-tree-node-palette-panel">
|
||||||
|
<BehaviorTreeNodePalette
|
||||||
|
onNodeSelect={(template) => {
|
||||||
|
messageHub?.publish('behavior-tree:node-palette-selected', { template });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
.behavior-tree-properties-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel-tabs .tab-button {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel-tabs .tab-button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel-tabs .tab-button.active {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom-color: var(--primary-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-panel-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Settings, Clipboard } from 'lucide-react';
|
||||||
|
import { Core } from '@esengine/ecs-framework';
|
||||||
|
import { MessageHub } from '@esengine/editor-core';
|
||||||
|
import { BehaviorTreeNodeProperties } from '../../../../components/BehaviorTreeNodeProperties';
|
||||||
|
import { BehaviorTreeBlackboard } from '../../../../components/BehaviorTreeBlackboard';
|
||||||
|
import { GlobalBlackboardService, type BlackboardValueType, type NodeTemplate } from '@esengine/behavior-tree';
|
||||||
|
import { useBehaviorTreeStore, type Connection } from '../../../../stores/behaviorTreeStore';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { useToast } from '../../../../components/Toast';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
import './BehaviorTreePropertiesPanel.css';
|
||||||
|
|
||||||
|
const logger = createLogger('BehaviorTreePropertiesPanel');
|
||||||
|
|
||||||
|
interface BehaviorTreePropertiesPanelProps {
|
||||||
|
projectPath?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BehaviorTreePropertiesPanel: React.FC<BehaviorTreePropertiesPanelProps> = ({ projectPath: propProjectPath }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const messageHub = Core.services.resolve(MessageHub);
|
||||||
|
|
||||||
|
const {
|
||||||
|
nodes,
|
||||||
|
connections,
|
||||||
|
updateNodes,
|
||||||
|
blackboardVariables,
|
||||||
|
setBlackboardVariables,
|
||||||
|
updateBlackboardVariable,
|
||||||
|
initialBlackboardVariables,
|
||||||
|
isExecuting,
|
||||||
|
removeConnections
|
||||||
|
} = useBehaviorTreeStore();
|
||||||
|
|
||||||
|
const [selectedNode, setSelectedNode] = useState<{
|
||||||
|
id: string;
|
||||||
|
template: NodeTemplate;
|
||||||
|
data: Record<string, any>;
|
||||||
|
} | undefined>();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'properties' | 'blackboard'>('blackboard');
|
||||||
|
const [projectPath, setProjectPath] = useState<string>('');
|
||||||
|
const [globalVariables, setGlobalVariables] = useState<Record<string, any>>({});
|
||||||
|
const [hasUnsavedGlobalChanges, setHasUnsavedGlobalChanges] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initProject = async () => {
|
||||||
|
if (propProjectPath) {
|
||||||
|
setProjectPath(propProjectPath);
|
||||||
|
localStorage.setItem('ecs-project-path', propProjectPath);
|
||||||
|
await loadGlobalBlackboard(propProjectPath);
|
||||||
|
} else {
|
||||||
|
const savedPath = localStorage.getItem('ecs-project-path');
|
||||||
|
if (savedPath) {
|
||||||
|
setProjectPath(savedPath);
|
||||||
|
await loadGlobalBlackboard(savedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initProject();
|
||||||
|
}, [propProjectPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateGlobalVariables = () => {
|
||||||
|
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||||
|
const allVars = globalBlackboard.getAllVariables();
|
||||||
|
const varsObject: Record<string, any> = {};
|
||||||
|
allVars.forEach((v) => {
|
||||||
|
varsObject[v.name] = v.value;
|
||||||
|
});
|
||||||
|
setGlobalVariables(varsObject);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateGlobalVariables();
|
||||||
|
const interval = setInterval(updateGlobalVariables, 100);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleNodeSelected = (data: any) => {
|
||||||
|
if (data.node) {
|
||||||
|
let template = data.node.template;
|
||||||
|
let nodeData = data.node.data;
|
||||||
|
|
||||||
|
if (data.node.data.nodeType === 'blackboard-variable') {
|
||||||
|
const varName = (data.node.data.variableName as string) || '';
|
||||||
|
const varValue = blackboardVariables[varName];
|
||||||
|
const varType = typeof varValue === 'number' ? 'number' :
|
||||||
|
typeof varValue === 'boolean' ? 'boolean' : 'string';
|
||||||
|
|
||||||
|
nodeData = {
|
||||||
|
...data.node.data,
|
||||||
|
__blackboardValue: varValue
|
||||||
|
};
|
||||||
|
|
||||||
|
template = {
|
||||||
|
...data.node.template,
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
name: 'variableName',
|
||||||
|
label: t('behaviorTree.variableName'),
|
||||||
|
type: 'variable',
|
||||||
|
defaultValue: varName,
|
||||||
|
description: t('behaviorTree.variableName'),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '__blackboardValue',
|
||||||
|
label: t('behaviorTree.currentValue'),
|
||||||
|
type: varType,
|
||||||
|
defaultValue: varValue,
|
||||||
|
description: t('behaviorTree.currentValue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedNode({
|
||||||
|
id: data.node.id,
|
||||||
|
template,
|
||||||
|
data: nodeData
|
||||||
|
});
|
||||||
|
setActiveTab('properties');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = messageHub?.subscribe('behavior-tree:node-selected', handleNodeSelected);
|
||||||
|
return () => unsubscribe?.();
|
||||||
|
}, [messageHub, blackboardVariables, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedNode && selectedNode.id) {
|
||||||
|
const nodeStillExists = nodes.some((node: any) => node.id === selectedNode.id);
|
||||||
|
if (!nodeStillExists) {
|
||||||
|
setSelectedNode(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [nodes, selectedNode]);
|
||||||
|
|
||||||
|
const loadGlobalBlackboard = async (path: string) => {
|
||||||
|
try {
|
||||||
|
const json = await invoke<string>('read_global_blackboard', { projectPath: path });
|
||||||
|
const config = JSON.parse(json);
|
||||||
|
Core.services.resolve(GlobalBlackboardService).importConfig(config);
|
||||||
|
|
||||||
|
const allVars = Core.services.resolve(GlobalBlackboardService).getAllVariables();
|
||||||
|
const varsObject: Record<string, any> = {};
|
||||||
|
allVars.forEach((v) => {
|
||||||
|
varsObject[v.name] = v.value;
|
||||||
|
});
|
||||||
|
setGlobalVariables(varsObject);
|
||||||
|
setHasUnsavedGlobalChanges(false);
|
||||||
|
logger.info('全局黑板配置已加载');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('加载全局黑板配置失败', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveGlobalBlackboard = async () => {
|
||||||
|
if (!projectPath) {
|
||||||
|
logger.error('未设置项目路径,无法保存全局黑板配置');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||||
|
const json = globalBlackboard.toJSON();
|
||||||
|
await invoke('write_global_blackboard', { projectPath, content: json });
|
||||||
|
setHasUnsavedGlobalChanges(false);
|
||||||
|
logger.info('全局黑板配置已保存到', `${projectPath}/.ecs/global-blackboard.json`);
|
||||||
|
showToast('全局黑板已保存', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('保存全局黑板配置失败', error);
|
||||||
|
showToast('保存全局黑板失败', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVariableChange = (key: string, value: any) => {
|
||||||
|
updateBlackboardVariable(key, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVariableAdd = (key: string, value: any) => {
|
||||||
|
updateBlackboardVariable(key, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVariableDelete = (key: string) => {
|
||||||
|
const newVars = { ...blackboardVariables };
|
||||||
|
delete newVars[key];
|
||||||
|
setBlackboardVariables(newVars);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVariableRename = (oldKey: string, newKey: string) => {
|
||||||
|
if (oldKey === newKey) return;
|
||||||
|
const newVars = { ...blackboardVariables };
|
||||||
|
const value = newVars[oldKey];
|
||||||
|
delete newVars[oldKey];
|
||||||
|
newVars[newKey] = value;
|
||||||
|
setBlackboardVariables(newVars);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGlobalVariableChange = (key: string, value: any) => {
|
||||||
|
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||||
|
globalBlackboard.setValue(key, value, true);
|
||||||
|
const allVars = globalBlackboard.getAllVariables();
|
||||||
|
const varsObject: Record<string, any> = {};
|
||||||
|
allVars.forEach((v) => {
|
||||||
|
varsObject[v.name] = v.value;
|
||||||
|
});
|
||||||
|
setGlobalVariables(varsObject);
|
||||||
|
setHasUnsavedGlobalChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGlobalVariableAdd = (key: string, value: any, type: BlackboardValueType) => {
|
||||||
|
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||||
|
globalBlackboard.defineVariable(key, type, value);
|
||||||
|
const allVars = globalBlackboard.getAllVariables();
|
||||||
|
const varsObject: Record<string, any> = {};
|
||||||
|
allVars.forEach((v) => {
|
||||||
|
varsObject[v.name] = v.value;
|
||||||
|
});
|
||||||
|
setGlobalVariables(varsObject);
|
||||||
|
setHasUnsavedGlobalChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGlobalVariableDelete = (key: string) => {
|
||||||
|
const globalBlackboard = Core.services.resolve(GlobalBlackboardService);
|
||||||
|
globalBlackboard.removeVariable(key);
|
||||||
|
const allVars = globalBlackboard.getAllVariables();
|
||||||
|
const varsObject: Record<string, any> = {};
|
||||||
|
allVars.forEach((v) => {
|
||||||
|
varsObject[v.name] = v.value;
|
||||||
|
});
|
||||||
|
setGlobalVariables(varsObject);
|
||||||
|
setHasUnsavedGlobalChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePropertyChange = (propertyName: string, value: any) => {
|
||||||
|
if (!selectedNode) return;
|
||||||
|
|
||||||
|
if (selectedNode.data.nodeType === 'blackboard-variable' && propertyName === '__blackboardValue') {
|
||||||
|
const varName = selectedNode.data.variableName;
|
||||||
|
if (varName) {
|
||||||
|
handleVariableChange(varName, value);
|
||||||
|
setSelectedNode({
|
||||||
|
...selectedNode,
|
||||||
|
data: {
|
||||||
|
...selectedNode.data,
|
||||||
|
__blackboardValue: value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.data.nodeType === 'blackboard-variable' && propertyName === 'variableName') {
|
||||||
|
const newVarValue = blackboardVariables[value];
|
||||||
|
const newVarType = typeof newVarValue === 'number' ? 'number' :
|
||||||
|
typeof newVarValue === 'boolean' ? 'boolean' : 'string';
|
||||||
|
|
||||||
|
updateNodes((nodes: any) => nodes.map((node: any) => {
|
||||||
|
if (node.id === selectedNode.id) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
[propertyName]: value
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
...node.template,
|
||||||
|
displayName: value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updatedTemplate = {
|
||||||
|
...selectedNode.template,
|
||||||
|
displayName: value,
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
name: 'variableName',
|
||||||
|
label: t('behaviorTree.variableName'),
|
||||||
|
type: 'variable' as const,
|
||||||
|
defaultValue: value,
|
||||||
|
description: t('behaviorTree.variableName'),
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '__blackboardValue',
|
||||||
|
label: t('behaviorTree.currentValue'),
|
||||||
|
type: newVarType as 'string' | 'number' | 'boolean',
|
||||||
|
defaultValue: newVarValue,
|
||||||
|
description: t('behaviorTree.currentValue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
setSelectedNode({
|
||||||
|
...selectedNode,
|
||||||
|
template: updatedTemplate,
|
||||||
|
data: {
|
||||||
|
...selectedNode.data,
|
||||||
|
[propertyName]: value,
|
||||||
|
__blackboardValue: newVarValue
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateNodes((nodes: any) => nodes.map((node: any) => {
|
||||||
|
if (node.id === selectedNode.id) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
[propertyName]: value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSelectedNode({
|
||||||
|
...selectedNode,
|
||||||
|
data: {
|
||||||
|
...selectedNode.data,
|
||||||
|
[propertyName]: value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="behavior-tree-properties-panel">
|
||||||
|
<div className="properties-panel-tabs">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('properties')}
|
||||||
|
className={`tab-button ${activeTab === 'properties' ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<Settings size={16} />
|
||||||
|
{t('behaviorTree.properties')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('blackboard')}
|
||||||
|
className={`tab-button ${activeTab === 'blackboard' ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<Clipboard size={16} />
|
||||||
|
{t('behaviorTree.blackboard')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="properties-panel-content">
|
||||||
|
{activeTab === 'properties' ? (
|
||||||
|
<BehaviorTreeNodeProperties
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
projectPath={projectPath}
|
||||||
|
onPropertyChange={handlePropertyChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BehaviorTreeBlackboard
|
||||||
|
variables={blackboardVariables}
|
||||||
|
initialVariables={isExecuting ? initialBlackboardVariables : undefined}
|
||||||
|
globalVariables={globalVariables}
|
||||||
|
onVariableChange={handleVariableChange}
|
||||||
|
onVariableAdd={handleVariableAdd}
|
||||||
|
onVariableDelete={handleVariableDelete}
|
||||||
|
onVariableRename={handleVariableRename}
|
||||||
|
onGlobalVariableChange={handleGlobalVariableChange}
|
||||||
|
onGlobalVariableAdd={handleGlobalVariableAdd}
|
||||||
|
onGlobalVariableDelete={handleGlobalVariableDelete}
|
||||||
|
projectPath={projectPath}
|
||||||
|
hasUnsavedGlobalChanges={hasUnsavedGlobalChanges}
|
||||||
|
onSaveGlobal={saveGlobalBlackboard}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { BehaviorTreeEditorPanel } from './BehaviorTreeEditorPanel';
|
||||||
|
export { BehaviorTreeNodePalettePanel } from './BehaviorTreeNodePalettePanel';
|
||||||
@@ -1,19 +1,36 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Trash2, Replace, Plus } from 'lucide-react';
|
||||||
|
|
||||||
interface NodeContextMenuProps {
|
interface NodeContextMenuProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
nodeId: string | null;
|
nodeId: string | null;
|
||||||
onReplaceNode: () => void;
|
onReplaceNode?: () => void;
|
||||||
|
onDeleteNode?: () => void;
|
||||||
|
onCreateNode?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
||||||
visible,
|
visible,
|
||||||
position,
|
position,
|
||||||
onReplaceNode
|
nodeId,
|
||||||
|
onReplaceNode,
|
||||||
|
onDeleteNode,
|
||||||
|
onCreateNode
|
||||||
}) => {
|
}) => {
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const menuItemStyle = {
|
||||||
|
padding: '8px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#cccccc',
|
||||||
|
fontSize: '13px',
|
||||||
|
transition: 'background-color 0.15s',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -30,20 +47,46 @@ export const NodeContextMenu: React.FC<NodeContextMenuProps> = ({
|
|||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
{nodeId ? (
|
||||||
|
<>
|
||||||
|
{onReplaceNode && (
|
||||||
<div
|
<div
|
||||||
onClick={onReplaceNode}
|
onClick={onReplaceNode}
|
||||||
style={{
|
style={menuItemStyle}
|
||||||
padding: '8px 16px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: '#cccccc',
|
|
||||||
fontSize: '13px',
|
|
||||||
transition: 'background-color 0.15s'
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
|
||||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
>
|
>
|
||||||
|
<Replace size={14} />
|
||||||
替换节点
|
替换节点
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{onDeleteNode && (
|
||||||
|
<div
|
||||||
|
onClick={onDeleteNode}
|
||||||
|
style={{...menuItemStyle, color: '#f48771'}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#5a1a1a'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
删除节点
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{onCreateNode && (
|
||||||
|
<div
|
||||||
|
onClick={onCreateNode}
|
||||||
|
style={menuItemStyle}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#094771'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
新建节点
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
import { NodeTemplate, NodeTemplates } from '@esengine/behavior-tree';
|
import { NodeTemplate, NodeTemplates } from '@esengine/behavior-tree';
|
||||||
import { Search, X, LucideIcon } from 'lucide-react';
|
import { Search, X, LucideIcon, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
interface QuickCreateMenuProps {
|
interface QuickCreateMenuProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -15,6 +15,12 @@ interface QuickCreateMenuProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CategoryGroup {
|
||||||
|
category: string;
|
||||||
|
templates: NodeTemplate[];
|
||||||
|
isExpanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
||||||
visible,
|
visible,
|
||||||
position,
|
position,
|
||||||
@@ -27,6 +33,8 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
|||||||
onClose
|
onClose
|
||||||
}) => {
|
}) => {
|
||||||
const selectedNodeRef = useRef<HTMLDivElement>(null);
|
const selectedNodeRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||||
|
const [shouldAutoScroll, setShouldAutoScroll] = useState(false);
|
||||||
|
|
||||||
const allTemplates = NodeTemplates.getAllTemplates();
|
const allTemplates = NodeTemplates.getAllTemplates();
|
||||||
const searchTextLower = searchText.toLowerCase();
|
const searchTextLower = searchText.toLowerCase();
|
||||||
@@ -40,17 +48,63 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
|||||||
})
|
})
|
||||||
: allTemplates;
|
: allTemplates;
|
||||||
|
|
||||||
|
const categoryGroups: CategoryGroup[] = React.useMemo(() => {
|
||||||
|
const groups = new Map<string, NodeTemplate[]>();
|
||||||
|
|
||||||
|
filteredTemplates.forEach((template: NodeTemplate) => {
|
||||||
|
const category = template.category || '未分类';
|
||||||
|
if (!groups.has(category)) {
|
||||||
|
groups.set(category, []);
|
||||||
|
}
|
||||||
|
groups.get(category)!.push(template);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(groups.entries()).map(([category, templates]) => ({
|
||||||
|
category,
|
||||||
|
templates,
|
||||||
|
isExpanded: searchTextLower ? true : expandedCategories.has(category)
|
||||||
|
})).sort((a, b) => a.category.localeCompare(b.category));
|
||||||
|
}, [filteredTemplates, expandedCategories, searchTextLower]);
|
||||||
|
|
||||||
|
const flattenedTemplates = React.useMemo(() => {
|
||||||
|
return categoryGroups.flatMap(group =>
|
||||||
|
group.isExpanded ? group.templates : []
|
||||||
|
);
|
||||||
|
}, [categoryGroups]);
|
||||||
|
|
||||||
|
const toggleCategory = (category: string) => {
|
||||||
|
setExpandedCategories(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(category)) {
|
||||||
|
newSet.delete(category);
|
||||||
|
} else {
|
||||||
|
newSet.add(category);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedNodeRef.current) {
|
if (allTemplates.length > 0 && expandedCategories.size === 0) {
|
||||||
|
const categories = new Set(allTemplates.map(t => t.category || '未分类'));
|
||||||
|
setExpandedCategories(categories);
|
||||||
|
}
|
||||||
|
}, [allTemplates, expandedCategories.size]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldAutoScroll && selectedNodeRef.current) {
|
||||||
selectedNodeRef.current.scrollIntoView({
|
selectedNodeRef.current.scrollIntoView({
|
||||||
block: 'nearest',
|
block: 'nearest',
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
});
|
});
|
||||||
|
setShouldAutoScroll(false);
|
||||||
}
|
}
|
||||||
}, [selectedIndex]);
|
}, [selectedIndex, shouldAutoScroll]);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
|
let globalIndex = -1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<style>{`
|
||||||
@@ -67,6 +121,12 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
|||||||
.quick-create-menu-list::-webkit-scrollbar-thumb:hover {
|
.quick-create-menu-list::-webkit-scrollbar-thumb:hover {
|
||||||
background: #4c4c4c;
|
background: #4c4c4c;
|
||||||
}
|
}
|
||||||
|
.category-header {
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
.category-header:hover {
|
||||||
|
background-color: #3c3c3c;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -74,7 +134,7 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
|||||||
left: `${position.x}px`,
|
left: `${position.x}px`,
|
||||||
top: `${position.y}px`,
|
top: `${position.y}px`,
|
||||||
width: '300px',
|
width: '300px',
|
||||||
maxHeight: '400px',
|
maxHeight: '500px',
|
||||||
backgroundColor: '#2d2d2d',
|
backgroundColor: '#2d2d2d',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||||
@@ -109,13 +169,15 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
|||||||
onClose();
|
onClose();
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onIndexChange(Math.min(selectedIndex + 1, filteredTemplates.length - 1));
|
setShouldAutoScroll(true);
|
||||||
|
onIndexChange(Math.min(selectedIndex + 1, flattenedTemplates.length - 1));
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setShouldAutoScroll(true);
|
||||||
onIndexChange(Math.max(selectedIndex - 1, 0));
|
onIndexChange(Math.max(selectedIndex - 1, 0));
|
||||||
} else if (e.key === 'Enter' && filteredTemplates.length > 0) {
|
} else if (e.key === 'Enter' && flattenedTemplates.length > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const selectedTemplate = filteredTemplates[selectedIndex];
|
const selectedTemplate = flattenedTemplates[selectedIndex];
|
||||||
if (selectedTemplate) {
|
if (selectedTemplate) {
|
||||||
onNodeSelect(selectedTemplate);
|
onNodeSelect(selectedTemplate);
|
||||||
}
|
}
|
||||||
@@ -153,10 +215,10 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
|||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
padding: '8px'
|
padding: '4px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{filteredTemplates.length === 0 ? (
|
{categoryGroups.length === 0 ? (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
@@ -166,16 +228,60 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
|||||||
未找到匹配的节点
|
未找到匹配的节点
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredTemplates.map((template: NodeTemplate, index: number) => {
|
categoryGroups.map((group) => {
|
||||||
|
return (
|
||||||
|
<div key={group.category} style={{ marginBottom: '4px' }}>
|
||||||
|
<div
|
||||||
|
className="category-header"
|
||||||
|
onClick={() => toggleCategory(group.category)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#1e1e1e',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.isExpanded ? (
|
||||||
|
<ChevronDown size={14} style={{ color: '#999', flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={14} style={{ color: '#999', flexShrink: 0 }} />
|
||||||
|
)}
|
||||||
|
<span style={{
|
||||||
|
color: '#aaa',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
flex: 1
|
||||||
|
}}>
|
||||||
|
{group.category}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '11px',
|
||||||
|
backgroundColor: '#2d2d2d',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '10px'
|
||||||
|
}}>
|
||||||
|
{group.templates.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{group.isExpanded && (
|
||||||
|
<div style={{ paddingLeft: '8px', paddingTop: '4px' }}>
|
||||||
|
{group.templates.map((template: NodeTemplate) => {
|
||||||
|
globalIndex++;
|
||||||
const IconComponent = template.icon ? iconMap[template.icon] : null;
|
const IconComponent = template.icon ? iconMap[template.icon] : null;
|
||||||
const className = template.className || '';
|
const className = template.className || '';
|
||||||
const isSelected = index === selectedIndex;
|
const isSelected = globalIndex === selectedIndex;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={template.className || template.displayName}
|
||||||
ref={isSelected ? selectedNodeRef : null}
|
ref={isSelected ? selectedNodeRef : null}
|
||||||
onClick={() => onNodeSelect(template)}
|
onClick={() => onNodeSelect(template)}
|
||||||
onMouseEnter={() => onIndexChange(index)}
|
onMouseEnter={() => onIndexChange(globalIndex)}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
marginBottom: '4px',
|
marginBottom: '4px',
|
||||||
@@ -220,17 +326,15 @@ export const QuickCreateMenu: React.FC<QuickCreateMenuProps> = ({
|
|||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: '#999',
|
color: '#999',
|
||||||
lineHeight: '1.4',
|
lineHeight: '1.4'
|
||||||
marginBottom: '2px'
|
|
||||||
}}>
|
}}>
|
||||||
{template.description}
|
{template.description}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
|
||||||
fontSize: '10px',
|
|
||||||
color: '#666'
|
|
||||||
}}>
|
|
||||||
{template.category}
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RefObject } from 'react';
|
import { RefObject, useEffect, useRef } from 'react';
|
||||||
import { BehaviorTreeNode, ROOT_NODE_ID } from '../../stores/behaviorTreeStore';
|
import { BehaviorTreeNode, ROOT_NODE_ID } from '../../stores/behaviorTreeStore';
|
||||||
|
|
||||||
interface QuickCreateMenuState {
|
interface QuickCreateMenuState {
|
||||||
@@ -31,6 +31,7 @@ interface UseCanvasMouseEventsParams {
|
|||||||
setQuickCreateMenu: (menu: QuickCreateMenuState) => void;
|
setQuickCreateMenu: (menu: QuickCreateMenuState) => void;
|
||||||
clearConnecting: () => void;
|
clearConnecting: () => void;
|
||||||
clearBoxSelect: () => void;
|
clearBoxSelect: () => void;
|
||||||
|
showToast?: (message: string, type: 'success' | 'error' | 'warning' | 'info', duration?: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
||||||
@@ -54,9 +55,87 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
|||||||
setSelectedConnection,
|
setSelectedConnection,
|
||||||
setQuickCreateMenu,
|
setQuickCreateMenu,
|
||||||
clearConnecting,
|
clearConnecting,
|
||||||
clearBoxSelect
|
clearBoxSelect,
|
||||||
|
showToast
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
|
const isBoxSelectingRef = useRef(isBoxSelecting);
|
||||||
|
const boxSelectStartRef = useRef(boxSelectStart);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isBoxSelectingRef.current = isBoxSelecting;
|
||||||
|
boxSelectStartRef.current = boxSelectStart;
|
||||||
|
}, [isBoxSelecting, boxSelectStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isBoxSelecting) return;
|
||||||
|
|
||||||
|
const handleGlobalMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isBoxSelectingRef.current || !boxSelectStartRef.current) return;
|
||||||
|
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
|
const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale;
|
||||||
|
const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale;
|
||||||
|
setBoxSelectEnd({ x: canvasX, y: canvasY });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGlobalMouseUp = (e: MouseEvent) => {
|
||||||
|
if (!isBoxSelectingRef.current || !boxSelectStartRef.current || !boxSelectEnd) return;
|
||||||
|
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) {
|
||||||
|
clearBoxSelect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minX = Math.min(boxSelectStartRef.current.x, boxSelectEnd.x);
|
||||||
|
const maxX = Math.max(boxSelectStartRef.current.x, boxSelectEnd.x);
|
||||||
|
const minY = Math.min(boxSelectStartRef.current.y, boxSelectEnd.y);
|
||||||
|
const maxY = Math.max(boxSelectStartRef.current.y, boxSelectEnd.y);
|
||||||
|
|
||||||
|
const selectedInBox = nodes
|
||||||
|
.filter((node: BehaviorTreeNode) => {
|
||||||
|
if (node.id === ROOT_NODE_ID) return false;
|
||||||
|
|
||||||
|
const nodeElement = canvasRef.current?.querySelector(`[data-node-id="${node.id}"]`);
|
||||||
|
if (!nodeElement) {
|
||||||
|
return node.position.x >= minX && node.position.x <= maxX &&
|
||||||
|
node.position.y >= minY && node.position.y <= maxY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeRect = nodeElement.getBoundingClientRect();
|
||||||
|
const canvasRect = canvasRef.current!.getBoundingClientRect();
|
||||||
|
|
||||||
|
const nodeLeft = (nodeRect.left - canvasRect.left - canvasOffset.x) / canvasScale;
|
||||||
|
const nodeRight = (nodeRect.right - canvasRect.left - canvasOffset.x) / canvasScale;
|
||||||
|
const nodeTop = (nodeRect.top - canvasRect.top - canvasOffset.y) / canvasScale;
|
||||||
|
const nodeBottom = (nodeRect.bottom - canvasRect.top - canvasOffset.y) / canvasScale;
|
||||||
|
|
||||||
|
return nodeRight > minX && nodeLeft < maxX && nodeBottom > minY && nodeTop < maxY;
|
||||||
|
})
|
||||||
|
.map((node: BehaviorTreeNode) => node.id);
|
||||||
|
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
const newSet = new Set([...selectedNodeIds, ...selectedInBox]);
|
||||||
|
setSelectedNodeIds(Array.from(newSet));
|
||||||
|
} else {
|
||||||
|
setSelectedNodeIds(selectedInBox);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearBoxSelect();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleGlobalMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleGlobalMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleGlobalMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleGlobalMouseUp);
|
||||||
|
};
|
||||||
|
}, [isBoxSelecting, boxSelectStart, boxSelectEnd, nodes, selectedNodeIds, canvasRef, canvasOffset, canvasScale, setBoxSelectEnd, setSelectedNodeIds, clearBoxSelect]);
|
||||||
|
|
||||||
const handleCanvasMouseMove = (e: React.MouseEvent) => {
|
const handleCanvasMouseMove = (e: React.MouseEvent) => {
|
||||||
if (connectingFrom && canvasRef.current && !quickCreateMenu.visible) {
|
if (connectingFrom && canvasRef.current && !quickCreateMenu.visible) {
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
@@ -67,15 +146,6 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
|||||||
y: canvasY
|
y: canvasY
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBoxSelecting && boxSelectStart) {
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
|
||||||
if (!rect) return;
|
|
||||||
|
|
||||||
const canvasX = (e.clientX - rect.left - canvasOffset.x) / canvasScale;
|
|
||||||
const canvasY = (e.clientY - rect.top - canvasOffset.y) / canvasScale;
|
|
||||||
setBoxSelectEnd({ x: canvasX, y: canvasY });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCanvasMouseUp = (e: React.MouseEvent) => {
|
const handleCanvasMouseUp = (e: React.MouseEvent) => {
|
||||||
@@ -84,6 +154,18 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (connectingFrom && connectingToPos) {
|
if (connectingFrom && connectingToPos) {
|
||||||
|
const sourceNode = nodes.find(n => n.id === connectingFrom);
|
||||||
|
if (sourceNode && !sourceNode.canAddChild()) {
|
||||||
|
const maxChildren = sourceNode.template.maxChildren ?? Infinity;
|
||||||
|
showToast?.(
|
||||||
|
`节点"${sourceNode.template.displayName}"已达到最大子节点数 ${maxChildren}`,
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
clearConnecting();
|
||||||
|
setConnectingToPos(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setQuickCreateMenu({
|
setQuickCreateMenu({
|
||||||
visible: true,
|
visible: true,
|
||||||
position: {
|
position: {
|
||||||
@@ -100,47 +182,21 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearConnecting();
|
clearConnecting();
|
||||||
|
|
||||||
if (isBoxSelecting && boxSelectStart && boxSelectEnd) {
|
|
||||||
const minX = Math.min(boxSelectStart.x, boxSelectEnd.x);
|
|
||||||
const maxX = Math.max(boxSelectStart.x, boxSelectEnd.x);
|
|
||||||
const minY = Math.min(boxSelectStart.y, boxSelectEnd.y);
|
|
||||||
const maxY = Math.max(boxSelectStart.y, boxSelectEnd.y);
|
|
||||||
|
|
||||||
const selectedInBox = nodes
|
|
||||||
.filter((node: BehaviorTreeNode) => {
|
|
||||||
if (node.id === ROOT_NODE_ID) return false;
|
|
||||||
|
|
||||||
const nodeElement = canvasRef.current?.querySelector(`[data-node-id="${node.id}"]`);
|
|
||||||
if (!nodeElement) {
|
|
||||||
return node.position.x >= minX && node.position.x <= maxX &&
|
|
||||||
node.position.y >= minY && node.position.y <= maxY;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rect = nodeElement.getBoundingClientRect();
|
|
||||||
const canvasRect = canvasRef.current!.getBoundingClientRect();
|
|
||||||
|
|
||||||
const nodeLeft = (rect.left - canvasRect.left - canvasOffset.x) / canvasScale;
|
|
||||||
const nodeRight = (rect.right - canvasRect.left - canvasOffset.x) / canvasScale;
|
|
||||||
const nodeTop = (rect.top - canvasRect.top - canvasOffset.y) / canvasScale;
|
|
||||||
const nodeBottom = (rect.bottom - canvasRect.top - canvasOffset.y) / canvasScale;
|
|
||||||
|
|
||||||
return nodeRight > minX && nodeLeft < maxX && nodeBottom > minY && nodeTop < maxY;
|
|
||||||
})
|
|
||||||
.map((node: BehaviorTreeNode) => node.id);
|
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey) {
|
|
||||||
const newSet = new Set([...selectedNodeIds, ...selectedInBox]);
|
|
||||||
setSelectedNodeIds(Array.from(newSet));
|
|
||||||
} else {
|
|
||||||
setSelectedNodeIds(selectedInBox);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearBoxSelect();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCanvasMouseDown = (e: React.MouseEvent) => {
|
const handleCanvasMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (quickCreateMenu.visible) {
|
||||||
|
setQuickCreateMenu({
|
||||||
|
visible: false,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
searchText: '',
|
||||||
|
selectedIndex: 0,
|
||||||
|
mode: 'create',
|
||||||
|
replaceNodeId: null
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.button === 0 && !e.altKey) {
|
if (e.button === 0 && !e.altKey) {
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { ExecutionController, ExecutionMode } from '../../application/services/ExecutionController';
|
import { ExecutionController, ExecutionMode } from '../../application/services/ExecutionController';
|
||||||
import { BlackboardManager } from '../../application/services/BlackboardManager';
|
import { BlackboardManager } from '../../application/services/BlackboardManager';
|
||||||
import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore';
|
import { BehaviorTreeNode, Connection, useBehaviorTreeStore } from '../../stores/behaviorTreeStore';
|
||||||
import { ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
import { ExecutionLog } from '../../utils/BehaviorTreeExecutor';
|
||||||
import { BlackboardValue } from '../../domain/models/Blackboard';
|
import { BlackboardValue } from '../../domain/models/Blackboard';
|
||||||
|
|
||||||
@@ -17,6 +17,8 @@ interface UseExecutionControllerParams {
|
|||||||
onBlackboardUpdate: (variables: BlackboardVariables) => void;
|
onBlackboardUpdate: (variables: BlackboardVariables) => void;
|
||||||
onInitialBlackboardSave: (variables: BlackboardVariables) => void;
|
onInitialBlackboardSave: (variables: BlackboardVariables) => void;
|
||||||
onExecutingChange: (isExecuting: boolean) => void;
|
onExecutingChange: (isExecuting: boolean) => void;
|
||||||
|
onSaveNodesDataSnapshot: () => void;
|
||||||
|
onRestoreNodesData: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useExecutionController(params: UseExecutionControllerParams) {
|
export function useExecutionController(params: UseExecutionControllerParams) {
|
||||||
@@ -28,7 +30,9 @@ export function useExecutionController(params: UseExecutionControllerParams) {
|
|||||||
connections,
|
connections,
|
||||||
onBlackboardUpdate,
|
onBlackboardUpdate,
|
||||||
onInitialBlackboardSave,
|
onInitialBlackboardSave,
|
||||||
onExecutingChange
|
onExecutingChange,
|
||||||
|
onSaveNodesDataSnapshot,
|
||||||
|
onRestoreNodesData
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const [executionMode, setExecutionMode] = useState<ExecutionMode>('idle');
|
const [executionMode, setExecutionMode] = useState<ExecutionMode>('idle');
|
||||||
@@ -42,9 +46,13 @@ export function useExecutionController(params: UseExecutionControllerParams) {
|
|||||||
projectPath,
|
projectPath,
|
||||||
onLogsUpdate: setExecutionLogs,
|
onLogsUpdate: setExecutionLogs,
|
||||||
onBlackboardUpdate,
|
onBlackboardUpdate,
|
||||||
onTickCountUpdate: setTickCount
|
onTickCountUpdate: setTickCount,
|
||||||
|
onExecutionStatusUpdate: (statuses, orders) => {
|
||||||
|
const store = useBehaviorTreeStore.getState();
|
||||||
|
store.updateNodeExecutionStatuses(statuses, orders);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, [rootNodeId, projectPath]);
|
}, [rootNodeId, projectPath, onBlackboardUpdate]);
|
||||||
|
|
||||||
const blackboardManager = useMemo(() => new BlackboardManager(), []);
|
const blackboardManager = useMemo(() => new BlackboardManager(), []);
|
||||||
|
|
||||||
@@ -70,11 +78,18 @@ export function useExecutionController(params: UseExecutionControllerParams) {
|
|||||||
});
|
});
|
||||||
}, [blackboardVariables, executionMode, controller]);
|
}, [blackboardVariables, executionMode, controller]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (executionMode === 'idle') return;
|
||||||
|
|
||||||
|
controller.updateNodes(nodes);
|
||||||
|
}, [nodes, executionMode, controller]);
|
||||||
|
|
||||||
const handlePlay = async () => {
|
const handlePlay = async () => {
|
||||||
try {
|
try {
|
||||||
blackboardManager.setInitialVariables(blackboardVariables);
|
blackboardManager.setInitialVariables(blackboardVariables);
|
||||||
blackboardManager.setCurrentVariables(blackboardVariables);
|
blackboardManager.setCurrentVariables(blackboardVariables);
|
||||||
onInitialBlackboardSave(blackboardManager.getInitialVariables());
|
onInitialBlackboardSave(blackboardManager.getInitialVariables());
|
||||||
|
onSaveNodesDataSnapshot();
|
||||||
onExecutingChange(true);
|
onExecutingChange(true);
|
||||||
|
|
||||||
setExecutionMode('running');
|
setExecutionMode('running');
|
||||||
@@ -104,6 +119,8 @@ export function useExecutionController(params: UseExecutionControllerParams) {
|
|||||||
|
|
||||||
const restoredVars = blackboardManager.restoreInitialVariables();
|
const restoredVars = blackboardManager.restoreInitialVariables();
|
||||||
onBlackboardUpdate(restoredVars);
|
onBlackboardUpdate(restoredVars);
|
||||||
|
onRestoreNodesData();
|
||||||
|
useBehaviorTreeStore.getState().clearNodeExecutionStatuses();
|
||||||
onExecutingChange(false);
|
onExecutingChange(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to stop execution:', error);
|
console.error('Failed to stop execution:', error);
|
||||||
|
|||||||
@@ -9,18 +9,29 @@ import { createRootNode, ROOT_NODE_ID } from '../domain/constants/RootNode';
|
|||||||
/**
|
/**
|
||||||
* 行为树 Store 状态接口
|
* 行为树 Store 状态接口
|
||||||
*/
|
*/
|
||||||
|
export type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure';
|
||||||
|
|
||||||
|
export interface NodeExecutionInfo {
|
||||||
|
status: NodeExecutionStatus;
|
||||||
|
executionOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface BehaviorTreeState {
|
interface BehaviorTreeState {
|
||||||
|
isOpen: boolean;
|
||||||
nodes: Node[];
|
nodes: Node[];
|
||||||
connections: Connection[];
|
connections: Connection[];
|
||||||
blackboard: Blackboard;
|
blackboard: Blackboard;
|
||||||
blackboardVariables: Record<string, BlackboardValue>;
|
blackboardVariables: Record<string, BlackboardValue>;
|
||||||
initialBlackboardVariables: Record<string, BlackboardValue>;
|
initialBlackboardVariables: Record<string, BlackboardValue>;
|
||||||
|
initialNodesData: Map<string, Record<string, unknown>>;
|
||||||
selectedNodeIds: string[];
|
selectedNodeIds: string[];
|
||||||
draggingNodeId: string | null;
|
draggingNodeId: string | null;
|
||||||
dragStartPositions: Map<string, { x: number; y: number }>;
|
dragStartPositions: Map<string, { x: number; y: number }>;
|
||||||
isDraggingNode: boolean;
|
isDraggingNode: boolean;
|
||||||
|
|
||||||
isExecuting: boolean;
|
isExecuting: boolean;
|
||||||
|
nodeExecutionStatuses: Map<string, NodeExecutionStatus>;
|
||||||
|
nodeExecutionOrders: Map<string, number>;
|
||||||
|
|
||||||
canvasOffset: { x: number; y: number };
|
canvasOffset: { x: number; y: number };
|
||||||
canvasScale: number;
|
canvasScale: number;
|
||||||
@@ -84,6 +95,11 @@ interface BehaviorTreeState {
|
|||||||
setBlackboardVariables: (variables: Record<string, BlackboardValue>) => void;
|
setBlackboardVariables: (variables: Record<string, BlackboardValue>) => void;
|
||||||
setInitialBlackboardVariables: (variables: Record<string, BlackboardValue>) => void;
|
setInitialBlackboardVariables: (variables: Record<string, BlackboardValue>) => void;
|
||||||
setIsExecuting: (isExecuting: boolean) => void;
|
setIsExecuting: (isExecuting: boolean) => void;
|
||||||
|
saveNodesDataSnapshot: () => void;
|
||||||
|
restoreNodesData: () => void;
|
||||||
|
setNodeExecutionStatus: (nodeId: string, status: NodeExecutionStatus) => void;
|
||||||
|
updateNodeExecutionStatuses: (statuses: Map<string, NodeExecutionStatus>, orders?: Map<string, number>) => void;
|
||||||
|
clearNodeExecutionStatuses: () => void;
|
||||||
|
|
||||||
sortChildrenByPosition: () => void;
|
sortChildrenByPosition: () => void;
|
||||||
|
|
||||||
@@ -95,6 +111,7 @@ interface BehaviorTreeState {
|
|||||||
format: 'json' | 'binary'
|
format: 'json' | 'binary'
|
||||||
) => string | Uint8Array;
|
) => string | Uint8Array;
|
||||||
|
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,17 +120,21 @@ interface BehaviorTreeState {
|
|||||||
* 行为树 Store
|
* 行为树 Store
|
||||||
*/
|
*/
|
||||||
export const useBehaviorTreeStore = create<BehaviorTreeState>((set, get) => ({
|
export const useBehaviorTreeStore = create<BehaviorTreeState>((set, get) => ({
|
||||||
nodes: [createRootNode()],
|
isOpen: false,
|
||||||
|
nodes: [],
|
||||||
connections: [],
|
connections: [],
|
||||||
blackboard: new Blackboard(),
|
blackboard: new Blackboard(),
|
||||||
blackboardVariables: {},
|
blackboardVariables: {},
|
||||||
initialBlackboardVariables: {},
|
initialBlackboardVariables: {},
|
||||||
|
initialNodesData: new Map(),
|
||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
draggingNodeId: null,
|
draggingNodeId: null,
|
||||||
dragStartPositions: new Map(),
|
dragStartPositions: new Map(),
|
||||||
isDraggingNode: false,
|
isDraggingNode: false,
|
||||||
|
|
||||||
isExecuting: false,
|
isExecuting: false,
|
||||||
|
nodeExecutionStatuses: new Map(),
|
||||||
|
nodeExecutionOrders: new Map(),
|
||||||
|
|
||||||
canvasOffset: { x: 0, y: 0 },
|
canvasOffset: { x: 0, y: 0 },
|
||||||
canvasScale: 1,
|
canvasScale: 1,
|
||||||
@@ -272,6 +293,45 @@ export const useBehaviorTreeStore = create<BehaviorTreeState>((set, get) => ({
|
|||||||
|
|
||||||
setIsExecuting: (isExecuting: boolean) => set({ isExecuting }),
|
setIsExecuting: (isExecuting: boolean) => set({ isExecuting }),
|
||||||
|
|
||||||
|
saveNodesDataSnapshot: () => {
|
||||||
|
const snapshot = new Map<string, Record<string, unknown>>();
|
||||||
|
get().nodes.forEach(node => {
|
||||||
|
snapshot.set(node.id, { ...node.data });
|
||||||
|
});
|
||||||
|
set({ initialNodesData: snapshot });
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreNodesData: () => {
|
||||||
|
const snapshot = get().initialNodesData;
|
||||||
|
if (snapshot.size === 0) return;
|
||||||
|
|
||||||
|
const updatedNodes = get().nodes.map(node => {
|
||||||
|
const savedData = snapshot.get(node.id);
|
||||||
|
if (savedData) {
|
||||||
|
return new Node(node.id, node.template, savedData, node.position, Array.from(node.children));
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
set({ nodes: updatedNodes, initialNodesData: new Map() });
|
||||||
|
},
|
||||||
|
|
||||||
|
setNodeExecutionStatus: (nodeId: string, status: NodeExecutionStatus) => {
|
||||||
|
const newStatuses = new Map(get().nodeExecutionStatuses);
|
||||||
|
newStatuses.set(nodeId, status);
|
||||||
|
set({ nodeExecutionStatuses: newStatuses });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNodeExecutionStatuses: (statuses: Map<string, NodeExecutionStatus>, orders?: Map<string, number>) => {
|
||||||
|
set({
|
||||||
|
nodeExecutionStatuses: new Map(statuses),
|
||||||
|
nodeExecutionOrders: orders ? new Map(orders) : new Map()
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearNodeExecutionStatuses: () => {
|
||||||
|
set({ nodeExecutionStatuses: new Map(), nodeExecutionOrders: new Map() });
|
||||||
|
},
|
||||||
|
|
||||||
sortChildrenByPosition: () => set((state: BehaviorTreeState) => {
|
sortChildrenByPosition: () => set((state: BehaviorTreeState) => {
|
||||||
const nodeMap = new Map<string, Node>();
|
const nodeMap = new Map<string, Node>();
|
||||||
state.nodes.forEach((node) => nodeMap.set(node.id, node));
|
state.nodes.forEach((node) => nodeMap.set(node.id, node));
|
||||||
@@ -353,6 +413,7 @@ export const useBehaviorTreeStore = create<BehaviorTreeState>((set, get) => ({
|
|||||||
const loadedBlackboard = Blackboard.fromObject(blackboardData);
|
const loadedBlackboard = Blackboard.fromObject(blackboardData);
|
||||||
|
|
||||||
set({
|
set({
|
||||||
|
isOpen: true,
|
||||||
nodes: loadedNodes,
|
nodes: loadedNodes,
|
||||||
connections: loadedConnections,
|
connections: loadedConnections,
|
||||||
blackboard: loadedBlackboard,
|
blackboard: loadedBlackboard,
|
||||||
@@ -391,8 +452,11 @@ export const useBehaviorTreeStore = create<BehaviorTreeState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setIsOpen: (isOpen: boolean) => set({ isOpen }),
|
||||||
|
|
||||||
reset: () => set({
|
reset: () => set({
|
||||||
nodes: [createRootNode()],
|
isOpen: false,
|
||||||
|
nodes: [],
|
||||||
connections: [],
|
connections: [],
|
||||||
blackboard: new Blackboard(),
|
blackboard: new Blackboard(),
|
||||||
blackboardVariables: {},
|
blackboardVariables: {},
|
||||||
|
|||||||
@@ -253,6 +253,14 @@
|
|||||||
color: #dcb67a;
|
color: #dcb67a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.asset-info {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.asset-name {
|
.asset-name {
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: #cccccc;
|
color: #cccccc;
|
||||||
|
|||||||
@@ -18,16 +18,30 @@
|
|||||||
animation: gentle-glow 2s ease-in-out infinite;
|
animation: gentle-glow 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bt-node.running::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -6px;
|
||||||
|
border: 2px solid #ffa726;
|
||||||
|
border-radius: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: pulse-border 1.5s ease-in-out infinite;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
.bt-node.success {
|
.bt-node.success {
|
||||||
box-shadow: 0 0 0 3px #4caf50, 0 4px 16px rgba(76, 175, 80, 0.6);
|
box-shadow: 0 0 0 3px #4caf50, 0 4px 16px rgba(76, 175, 80, 0.6);
|
||||||
|
background: linear-gradient(135deg, rgba(76, 175, 80, 0.1) 0%, transparent 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-node.failure {
|
.bt-node.failure {
|
||||||
box-shadow: 0 0 0 3px #f44336, 0 4px 16px rgba(244, 67, 54, 0.6);
|
box-shadow: 0 0 0 3px #f44336, 0 4px 16px rgba(244, 67, 54, 0.6);
|
||||||
|
background: linear-gradient(135deg, rgba(244, 67, 54, 0.1) 0%, transparent 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-node.executed {
|
.bt-node.executed {
|
||||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.3), 0 2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
background: linear-gradient(135deg, rgba(76, 175, 80, 0.05) 0%, transparent 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bt-node.root {
|
.bt-node.root {
|
||||||
@@ -219,6 +233,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-border {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes success-flash {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 8px rgba(76, 175, 80, 0.8), 0 6px 24px rgba(76, 175, 80, 0.8);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 3px #4caf50, 0 4px 16px rgba(76, 175, 80, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes failure-flash {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 8px rgba(244, 67, 54, 0.8), 0 6px 24px rgba(244, 67, 54, 0.8);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 3px #f44336, 0 4px 16px rgba(244, 67, 54, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.bt-node-empty-warning-tooltip {
|
.bt-node-empty-warning-tooltip {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -61,7 +61,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.inspector-section {
|
.inspector-section {
|
||||||
margin-bottom: var(--spacing-lg);
|
margin-bottom: 20px;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inspector-section:last-child {
|
.inspector-section:last-child {
|
||||||
@@ -297,3 +300,151 @@
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: -4px -4px 12px -4px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-field {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 8px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 16px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.01);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-field:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-field:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-label {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 80px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-value-text {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-remove-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-header:hover .component-remove-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-remove-btn:hover {
|
||||||
|
background-color: var(--color-error);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-inspector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
text-align: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-node-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-node-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-node-index {
|
||||||
|
color: #666;
|
||||||
|
min-width: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-node-name {
|
||||||
|
color: #ccc;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-content {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid #3e3e3e;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #d4d4d4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-content::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-content::-webkit-scrollbar-track {
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-content::-webkit-scrollbar-thumb {
|
||||||
|
background: #424242;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #4e4e4e;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,35 @@
|
|||||||
|
.file-tree-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #252526;
|
||||||
|
border-bottom: 1px solid #3e3e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tree-toolbar-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #cccccc;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tree-toolbar-btn:hover {
|
||||||
|
background: #2a2d2e;
|
||||||
|
border-color: #3e3e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-tree-toolbar-btn:active {
|
||||||
|
background: #37373d;
|
||||||
|
}
|
||||||
|
|
||||||
.file-tree {
|
.file-tree {
|
||||||
height: 100%;
|
height: calc(100% - 32px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
@@ -76,3 +106,15 @@
|
|||||||
.tree-children {
|
.tree-children {
|
||||||
/* Children are indented via inline style */
|
/* Children are indented via inline style */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-rename-input {
|
||||||
|
flex: 1;
|
||||||
|
background: #3c3c3c;
|
||||||
|
border: 1px solid #007acc;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
color: #cccccc;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|||||||
153
packages/editor-app/src/styles/PromptDialog.css
Normal file
153
packages/editor-app/src/styles/PromptDialog.css
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
.prompt-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog {
|
||||||
|
background: var(--bg-secondary, #2a2a2a);
|
||||||
|
border: 1px solid var(--border-color, #404040);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 400px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #404040);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-header .close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-header .close-btn:hover {
|
||||||
|
background: var(--bg-hover, #3a3a3a);
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-content {
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-content p {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid var(--border-color, #404040);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-input:focus {
|
||||||
|
border-color: #4a9eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-input::placeholder {
|
||||||
|
color: var(--text-tertiary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-footer {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid var(--border-color, #404040);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-btn {
|
||||||
|
padding: 8px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-btn.cancel {
|
||||||
|
background: var(--bg-hover, #3a3a3a);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-btn.cancel:hover {
|
||||||
|
background: var(--bg-active, #4a4a4a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-btn.confirm {
|
||||||
|
background: #4a9eff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-btn.confirm:hover {
|
||||||
|
background: #6bb0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-btn.confirm:disabled {
|
||||||
|
background: #3a3a3a;
|
||||||
|
color: #666;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-dialog-btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export interface ExecutionStatus {
|
|||||||
nodeId: string;
|
nodeId: string;
|
||||||
status: 'running' | 'success' | 'failure' | 'idle';
|
status: 'running' | 'success' | 'failure' | 'idle';
|
||||||
message?: string;
|
message?: string;
|
||||||
|
executionOrder?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecutionLog {
|
export interface ExecutionLog {
|
||||||
@@ -47,9 +48,12 @@ export class BehaviorTreeExecutor {
|
|||||||
private isPaused = false;
|
private isPaused = false;
|
||||||
private executionLogs: ExecutionLog[] = [];
|
private executionLogs: ExecutionLog[] = [];
|
||||||
private lastStatuses: Map<string, 'running' | 'success' | 'failure' | 'idle'> = new Map();
|
private lastStatuses: Map<string, 'running' | 'success' | 'failure' | 'idle'> = new Map();
|
||||||
|
private persistentStatuses: Map<string, 'running' | 'success' | 'failure' | 'idle'> = new Map();
|
||||||
|
private executionOrders: Map<string, number> = new Map();
|
||||||
private tickCount = 0;
|
private tickCount = 0;
|
||||||
private nodeIdMap: Map<string, string> = new Map();
|
private nodeIdMap: Map<string, string> = new Map();
|
||||||
private blackboardKeys: string[] = [];
|
private blackboardKeys: string[] = [];
|
||||||
|
private rootNodeId: string = '';
|
||||||
|
|
||||||
private assetManager: BehaviorTreeAssetManager;
|
private assetManager: BehaviorTreeAssetManager;
|
||||||
private executionSystem: BehaviorTreeExecutionSystem;
|
private executionSystem: BehaviorTreeExecutionSystem;
|
||||||
@@ -84,6 +88,7 @@ export class BehaviorTreeExecutor {
|
|||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
|
|
||||||
this.treeData = this.convertToTreeData(nodes, rootNodeId, blackboard, connections);
|
this.treeData = this.convertToTreeData(nodes, rootNodeId, blackboard, connections);
|
||||||
|
this.rootNodeId = this.treeData.rootNodeId;
|
||||||
|
|
||||||
this.assetManager.loadAsset(this.treeData);
|
this.assetManager.loadAsset(this.treeData);
|
||||||
|
|
||||||
@@ -249,6 +254,7 @@ export class BehaviorTreeExecutor {
|
|||||||
this.isPaused = false;
|
this.isPaused = false;
|
||||||
this.executionLogs = [];
|
this.executionLogs = [];
|
||||||
this.lastStatuses.clear();
|
this.lastStatuses.clear();
|
||||||
|
this.persistentStatuses.clear();
|
||||||
this.tickCount = 0;
|
this.tickCount = 0;
|
||||||
|
|
||||||
this.runtime.resetAllStates();
|
this.runtime.resetAllStates();
|
||||||
@@ -313,38 +319,102 @@ export class BehaviorTreeExecutor {
|
|||||||
private collectExecutionStatus(): void {
|
private collectExecutionStatus(): void {
|
||||||
if (!this.callback || !this.runtime || !this.treeData) return;
|
if (!this.callback || !this.runtime || !this.treeData) return;
|
||||||
|
|
||||||
|
const rootState = this.runtime.getNodeState(this.rootNodeId);
|
||||||
|
let rootCurrentStatus: 'running' | 'success' | 'failure' | 'idle' = 'idle';
|
||||||
|
|
||||||
|
if (rootState) {
|
||||||
|
switch (rootState.status) {
|
||||||
|
case TaskStatus.Running:
|
||||||
|
rootCurrentStatus = 'running';
|
||||||
|
break;
|
||||||
|
case TaskStatus.Success:
|
||||||
|
rootCurrentStatus = 'success';
|
||||||
|
break;
|
||||||
|
case TaskStatus.Failure:
|
||||||
|
rootCurrentStatus = 'failure';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
rootCurrentStatus = 'idle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootLastStatus = this.lastStatuses.get(this.rootNodeId);
|
||||||
|
|
||||||
|
if (rootLastStatus &&
|
||||||
|
(rootLastStatus === 'success' || rootLastStatus === 'failure') &&
|
||||||
|
rootCurrentStatus === 'running') {
|
||||||
|
this.persistentStatuses.clear();
|
||||||
|
this.executionOrders.clear();
|
||||||
|
}
|
||||||
|
|
||||||
const statuses: ExecutionStatus[] = [];
|
const statuses: ExecutionStatus[] = [];
|
||||||
|
|
||||||
for (const [nodeId, nodeData] of this.treeData.nodes.entries()) {
|
for (const [nodeId, nodeData] of this.treeData.nodes.entries()) {
|
||||||
const state = this.runtime.getNodeState(nodeId);
|
const state = this.runtime.getNodeState(nodeId);
|
||||||
|
|
||||||
let status: 'running' | 'success' | 'failure' | 'idle' = 'idle';
|
let currentStatus: 'running' | 'success' | 'failure' | 'idle' = 'idle';
|
||||||
|
|
||||||
if (state) {
|
if (state) {
|
||||||
switch (state.status) {
|
switch (state.status) {
|
||||||
case TaskStatus.Success:
|
case TaskStatus.Success:
|
||||||
status = 'success';
|
currentStatus = 'success';
|
||||||
break;
|
break;
|
||||||
case TaskStatus.Failure:
|
case TaskStatus.Failure:
|
||||||
status = 'failure';
|
currentStatus = 'failure';
|
||||||
break;
|
break;
|
||||||
case TaskStatus.Running:
|
case TaskStatus.Running:
|
||||||
status = 'running';
|
currentStatus = 'running';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
status = 'idle';
|
currentStatus = 'idle';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const persistentStatus = this.persistentStatuses.get(nodeId) || 'idle';
|
||||||
const lastStatus = this.lastStatuses.get(nodeId);
|
const lastStatus = this.lastStatuses.get(nodeId);
|
||||||
if (lastStatus !== status) {
|
|
||||||
this.onNodeStatusChanged(nodeId, nodeData.name, lastStatus || 'idle', status);
|
let displayStatus: 'running' | 'success' | 'failure' | 'idle' = currentStatus;
|
||||||
this.lastStatuses.set(nodeId, status);
|
|
||||||
|
if (currentStatus === 'running') {
|
||||||
|
displayStatus = 'running';
|
||||||
|
this.persistentStatuses.set(nodeId, 'running');
|
||||||
|
} else if (currentStatus === 'success') {
|
||||||
|
displayStatus = 'success';
|
||||||
|
this.persistentStatuses.set(nodeId, 'success');
|
||||||
|
} else if (currentStatus === 'failure') {
|
||||||
|
displayStatus = 'failure';
|
||||||
|
this.persistentStatuses.set(nodeId, 'failure');
|
||||||
|
} else if (currentStatus === 'idle') {
|
||||||
|
if (persistentStatus !== 'idle') {
|
||||||
|
displayStatus = persistentStatus;
|
||||||
|
} else if (this.executionOrders.has(nodeId)) {
|
||||||
|
displayStatus = 'success';
|
||||||
|
this.persistentStatuses.set(nodeId, 'success');
|
||||||
|
} else {
|
||||||
|
displayStatus = 'idle';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测状态变化
|
||||||
|
const hasStateChanged = lastStatus !== currentStatus;
|
||||||
|
|
||||||
|
// 从运行时状态读取执行顺序
|
||||||
|
if (state?.executionOrder !== undefined && !this.executionOrders.has(nodeId)) {
|
||||||
|
this.executionOrders.set(nodeId, state.executionOrder);
|
||||||
|
console.log(`[ExecutionOrder READ] ${nodeData.name} | ID: ${nodeId} | Order: ${state.executionOrder}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录状态变化日志
|
||||||
|
if (hasStateChanged && currentStatus !== 'idle') {
|
||||||
|
this.onNodeStatusChanged(nodeId, nodeData.name, lastStatus || 'idle', currentStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastStatuses.set(nodeId, currentStatus);
|
||||||
|
|
||||||
statuses.push({
|
statuses.push({
|
||||||
nodeId,
|
nodeId,
|
||||||
status
|
status: displayStatus,
|
||||||
|
executionOrder: this.executionOrders.get(nodeId)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +498,7 @@ export class BehaviorTreeExecutor {
|
|||||||
this.stop();
|
this.stop();
|
||||||
this.nodeIdMap.clear();
|
this.nodeIdMap.clear();
|
||||||
this.lastStatuses.clear();
|
this.lastStatuses.clear();
|
||||||
|
this.persistentStatuses.clear();
|
||||||
this.blackboardKeys = [];
|
this.blackboardKeys = [];
|
||||||
|
|
||||||
if (this.entity) {
|
if (this.entity) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { EditorPluginCategory } from './IEditorPlugin';
|
|||||||
import { UIRegistry } from '../Services/UIRegistry';
|
import { UIRegistry } from '../Services/UIRegistry';
|
||||||
import { MessageHub } from '../Services/MessageHub';
|
import { MessageHub } from '../Services/MessageHub';
|
||||||
import { SerializerRegistry } from '../Services/SerializerRegistry';
|
import { SerializerRegistry } from '../Services/SerializerRegistry';
|
||||||
|
import { FileActionRegistry } from '../Services/FileActionRegistry';
|
||||||
|
|
||||||
const logger = createLogger('EditorPluginManager');
|
const logger = createLogger('EditorPluginManager');
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export class EditorPluginManager extends PluginManager {
|
|||||||
private uiRegistry: UIRegistry | null = null;
|
private uiRegistry: UIRegistry | null = null;
|
||||||
private messageHub: MessageHub | null = null;
|
private messageHub: MessageHub | null = null;
|
||||||
private serializerRegistry: SerializerRegistry | null = null;
|
private serializerRegistry: SerializerRegistry | null = null;
|
||||||
|
private fileActionRegistry: FileActionRegistry | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化编辑器插件管理器
|
* 初始化编辑器插件管理器
|
||||||
@@ -32,6 +34,7 @@ export class EditorPluginManager extends PluginManager {
|
|||||||
this.uiRegistry = services.resolve(UIRegistry);
|
this.uiRegistry = services.resolve(UIRegistry);
|
||||||
this.messageHub = services.resolve(MessageHub);
|
this.messageHub = services.resolve(MessageHub);
|
||||||
this.serializerRegistry = services.resolve(SerializerRegistry);
|
this.serializerRegistry = services.resolve(SerializerRegistry);
|
||||||
|
this.fileActionRegistry = services.resolve(FileActionRegistry);
|
||||||
|
|
||||||
logger.info('EditorPluginManager initialized');
|
logger.info('EditorPluginManager initialized');
|
||||||
}
|
}
|
||||||
@@ -90,6 +93,24 @@ export class EditorPluginManager extends PluginManager {
|
|||||||
logger.debug(`Registered ${serializers.length} serializers for ${plugin.name}`);
|
logger.debug(`Registered ${serializers.length} serializers for ${plugin.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (plugin.registerFileActionHandlers && this.fileActionRegistry) {
|
||||||
|
const handlers = plugin.registerFileActionHandlers();
|
||||||
|
console.log(`[EditorPluginManager] Registering ${handlers.length} file action handlers for ${plugin.name}`);
|
||||||
|
for (const handler of handlers) {
|
||||||
|
console.log(`[EditorPluginManager] Handler for extensions:`, handler.extensions);
|
||||||
|
this.fileActionRegistry.registerActionHandler(handler);
|
||||||
|
}
|
||||||
|
logger.debug(`Registered ${handlers.length} file action handlers for ${plugin.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugin.registerFileCreationTemplates && this.fileActionRegistry) {
|
||||||
|
const templates = plugin.registerFileCreationTemplates();
|
||||||
|
for (const template of templates) {
|
||||||
|
this.fileActionRegistry.registerCreationTemplate(template);
|
||||||
|
}
|
||||||
|
logger.debug(`Registered ${templates.length} file creation templates for ${plugin.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (plugin.onEditorReady) {
|
if (plugin.onEditorReady) {
|
||||||
await plugin.onEditorReady();
|
await plugin.onEditorReady();
|
||||||
}
|
}
|
||||||
@@ -332,6 +353,7 @@ export class EditorPluginManager extends PluginManager {
|
|||||||
this.uiRegistry = null;
|
this.uiRegistry = null;
|
||||||
this.messageHub = null;
|
this.messageHub = null;
|
||||||
this.serializerRegistry = null;
|
this.serializerRegistry = null;
|
||||||
|
this.fileActionRegistry = null;
|
||||||
|
|
||||||
logger.info('EditorPluginManager disposed');
|
logger.info('EditorPluginManager disposed');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { IPlugin } from '@esengine/ecs-framework';
|
import type { IPlugin } from '@esengine/ecs-framework';
|
||||||
import type { MenuItem, ToolbarItem, PanelDescriptor } from '../Types/UITypes';
|
import type { MenuItem, ToolbarItem, PanelDescriptor } from '../Types/UITypes';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 编辑器插件类别
|
* 编辑器插件类别
|
||||||
@@ -51,6 +52,91 @@ export interface ISerializer<T = any> {
|
|||||||
getSupportedType(): string;
|
getSupportedType(): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件上下文菜单项
|
||||||
|
*/
|
||||||
|
export interface FileContextMenuItem {
|
||||||
|
/**
|
||||||
|
* 菜单项标签
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标
|
||||||
|
*/
|
||||||
|
icon?: ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击处理函数
|
||||||
|
*/
|
||||||
|
onClick: (filePath: string, parentPath: string) => void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否禁用
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为分隔符
|
||||||
|
*/
|
||||||
|
separator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件创建模板
|
||||||
|
*/
|
||||||
|
export interface FileCreationTemplate {
|
||||||
|
/**
|
||||||
|
* 模板名称
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件扩展名(不含点)
|
||||||
|
*/
|
||||||
|
extension: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认文件名
|
||||||
|
*/
|
||||||
|
defaultFileName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标
|
||||||
|
*/
|
||||||
|
icon?: ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建文件内容的函数
|
||||||
|
*/
|
||||||
|
createContent: (fileName: string) => string | Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件操作处理器
|
||||||
|
*/
|
||||||
|
export interface FileActionHandler {
|
||||||
|
/**
|
||||||
|
* 支持的文件扩展名列表
|
||||||
|
*/
|
||||||
|
extensions: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双击处理函数
|
||||||
|
*/
|
||||||
|
onDoubleClick?: (filePath: string) => void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开文件处理函数
|
||||||
|
*/
|
||||||
|
onOpen?: (filePath: string) => void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取上下文菜单项
|
||||||
|
*/
|
||||||
|
getContextMenuItems?: (filePath: string, parentPath: string) => FileContextMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 编辑器插件接口
|
* 编辑器插件接口
|
||||||
*
|
*
|
||||||
@@ -131,6 +217,16 @@ export interface IEditorPlugin extends IPlugin {
|
|||||||
* 获取行为树节点模板
|
* 获取行为树节点模板
|
||||||
*/
|
*/
|
||||||
getNodeTemplates?(): any[];
|
getNodeTemplates?(): any[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册文件操作处理器
|
||||||
|
*/
|
||||||
|
registerFileActionHandlers?(): FileActionHandler[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册文件创建模板
|
||||||
|
*/
|
||||||
|
registerFileCreationTemplates?(): FileCreationTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
110
packages/editor-core/src/Services/FileActionRegistry.ts
Normal file
110
packages/editor-core/src/Services/FileActionRegistry.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { IService } from '@esengine/ecs-framework';
|
||||||
|
import { FileActionHandler, FileCreationTemplate } from '../Plugins/IEditorPlugin';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文件操作注册表服务
|
||||||
|
*
|
||||||
|
* 管理插件注册的文件操作处理器和文件创建模板
|
||||||
|
*/
|
||||||
|
export class FileActionRegistry implements IService {
|
||||||
|
private actionHandlers: Map<string, FileActionHandler[]> = new Map();
|
||||||
|
private creationTemplates: FileCreationTemplate[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册文件操作处理器
|
||||||
|
*/
|
||||||
|
registerActionHandler(handler: FileActionHandler): void {
|
||||||
|
for (const ext of handler.extensions) {
|
||||||
|
const handlers = this.actionHandlers.get(ext) || [];
|
||||||
|
handlers.push(handler);
|
||||||
|
this.actionHandlers.set(ext, handlers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册文件创建模板
|
||||||
|
*/
|
||||||
|
registerCreationTemplate(template: FileCreationTemplate): void {
|
||||||
|
this.creationTemplates.push(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件扩展名的处理器
|
||||||
|
*/
|
||||||
|
getHandlersForExtension(extension: string): FileActionHandler[] {
|
||||||
|
return this.actionHandlers.get(extension) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件的处理器
|
||||||
|
*/
|
||||||
|
getHandlersForFile(filePath: string): FileActionHandler[] {
|
||||||
|
const extension = this.getFileExtension(filePath);
|
||||||
|
return extension ? this.getHandlersForExtension(extension) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有文件创建模板
|
||||||
|
*/
|
||||||
|
getCreationTemplates(): FileCreationTemplate[] {
|
||||||
|
return this.creationTemplates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文件双击
|
||||||
|
*/
|
||||||
|
async handleDoubleClick(filePath: string): Promise<boolean> {
|
||||||
|
const extension = this.getFileExtension(filePath);
|
||||||
|
console.log('[FileActionRegistry] handleDoubleClick:', filePath);
|
||||||
|
console.log('[FileActionRegistry] Extension:', extension);
|
||||||
|
console.log('[FileActionRegistry] Total handlers:', this.actionHandlers.size);
|
||||||
|
console.log('[FileActionRegistry] Registered extensions:', Array.from(this.actionHandlers.keys()));
|
||||||
|
|
||||||
|
const handlers = this.getHandlersForFile(filePath);
|
||||||
|
console.log('[FileActionRegistry] Found handlers:', handlers.length);
|
||||||
|
|
||||||
|
for (const handler of handlers) {
|
||||||
|
if (handler.onDoubleClick) {
|
||||||
|
console.log('[FileActionRegistry] Calling handler for extensions:', handler.extensions);
|
||||||
|
await handler.onDoubleClick(filePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文件打开
|
||||||
|
*/
|
||||||
|
async handleOpen(filePath: string): Promise<boolean> {
|
||||||
|
const handlers = this.getHandlersForFile(filePath);
|
||||||
|
for (const handler of handlers) {
|
||||||
|
if (handler.onOpen) {
|
||||||
|
await handler.onOpen(filePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有注册
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.actionHandlers.clear();
|
||||||
|
this.creationTemplates = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放资源
|
||||||
|
*/
|
||||||
|
dispose(): void {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFileExtension(filePath: string): string | null {
|
||||||
|
const lastDot = filePath.lastIndexOf('.');
|
||||||
|
if (lastDot === -1) return null;
|
||||||
|
return filePath.substring(lastDot + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
180
packages/editor-core/src/Services/WindowRegistry.ts
Normal file
180
packages/editor-core/src/Services/WindowRegistry.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { IService } from '@esengine/ecs-framework';
|
||||||
|
import { ComponentType } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 窗口描述符
|
||||||
|
*/
|
||||||
|
export interface WindowDescriptor {
|
||||||
|
/**
|
||||||
|
* 窗口唯一标识
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 窗口组件
|
||||||
|
*/
|
||||||
|
component: ComponentType<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 窗口标题
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认宽度
|
||||||
|
*/
|
||||||
|
defaultWidth?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认高度
|
||||||
|
*/
|
||||||
|
defaultHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 窗口实例
|
||||||
|
*/
|
||||||
|
export interface WindowInstance {
|
||||||
|
/**
|
||||||
|
* 窗口描述符
|
||||||
|
*/
|
||||||
|
descriptor: WindowDescriptor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否打开
|
||||||
|
*/
|
||||||
|
isOpen: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 窗口参数
|
||||||
|
*/
|
||||||
|
params?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 窗口注册表服务
|
||||||
|
*
|
||||||
|
* 管理插件注册的窗口组件
|
||||||
|
*/
|
||||||
|
export class WindowRegistry implements IService {
|
||||||
|
private windows: Map<string, WindowDescriptor> = new Map();
|
||||||
|
private openWindows: Map<string, WindowInstance> = new Map();
|
||||||
|
private listeners: Set<() => void> = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册窗口
|
||||||
|
*/
|
||||||
|
registerWindow(descriptor: WindowDescriptor): void {
|
||||||
|
if (this.windows.has(descriptor.id)) {
|
||||||
|
console.warn(`Window ${descriptor.id} is already registered`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.windows.set(descriptor.id, descriptor);
|
||||||
|
console.log(`[WindowRegistry] Registered window: ${descriptor.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消注册窗口
|
||||||
|
*/
|
||||||
|
unregisterWindow(windowId: string): void {
|
||||||
|
this.windows.delete(windowId);
|
||||||
|
this.openWindows.delete(windowId);
|
||||||
|
this.notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取窗口描述符
|
||||||
|
*/
|
||||||
|
getWindow(windowId: string): WindowDescriptor | undefined {
|
||||||
|
return this.windows.get(windowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有窗口描述符
|
||||||
|
*/
|
||||||
|
getAllWindows(): WindowDescriptor[] {
|
||||||
|
return Array.from(this.windows.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开窗口
|
||||||
|
*/
|
||||||
|
openWindow(windowId: string, params?: Record<string, any>): void {
|
||||||
|
const descriptor = this.windows.get(windowId);
|
||||||
|
if (!descriptor) {
|
||||||
|
console.warn(`Window ${windowId} is not registered`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[WindowRegistry] Opening window: ${windowId}`, params);
|
||||||
|
this.openWindows.set(windowId, {
|
||||||
|
descriptor,
|
||||||
|
isOpen: true,
|
||||||
|
params
|
||||||
|
});
|
||||||
|
this.notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭窗口
|
||||||
|
*/
|
||||||
|
closeWindow(windowId: string): void {
|
||||||
|
console.log(`[WindowRegistry] Closing window: ${windowId}`);
|
||||||
|
this.openWindows.delete(windowId);
|
||||||
|
this.notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取打开的窗口实例
|
||||||
|
*/
|
||||||
|
getOpenWindow(windowId: string): WindowInstance | undefined {
|
||||||
|
return this.openWindows.get(windowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有打开的窗口
|
||||||
|
*/
|
||||||
|
getAllOpenWindows(): WindowInstance[] {
|
||||||
|
return Array.from(this.openWindows.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查窗口是否打开
|
||||||
|
*/
|
||||||
|
isWindowOpen(windowId: string): boolean {
|
||||||
|
return this.openWindows.has(windowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加变化监听器
|
||||||
|
*/
|
||||||
|
addListener(listener: () => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知所有监听器
|
||||||
|
*/
|
||||||
|
private notifyListeners(): void {
|
||||||
|
this.listeners.forEach(listener => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有窗口
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.windows.clear();
|
||||||
|
this.openWindows.clear();
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 释放资源
|
||||||
|
*/
|
||||||
|
dispose(): void {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,6 +146,11 @@ export interface PanelDescriptor {
|
|||||||
* 排序权重
|
* 排序权重
|
||||||
*/
|
*/
|
||||||
order?: number;
|
order?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为动态面板(不默认显示,需要手动打开)
|
||||||
|
*/
|
||||||
|
isDynamic?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export * from './Services/ComponentDiscoveryService';
|
|||||||
export * from './Services/LogService';
|
export * from './Services/LogService';
|
||||||
export * from './Services/SettingsRegistry';
|
export * from './Services/SettingsRegistry';
|
||||||
export * from './Services/SceneManagerService';
|
export * from './Services/SceneManagerService';
|
||||||
|
export * from './Services/FileActionRegistry';
|
||||||
|
|
||||||
export * from './Types/UITypes';
|
export * from './Types/UITypes';
|
||||||
export * from './Types/IFileAPI';
|
export * from './Types/IFileAPI';
|
||||||
|
|||||||
Reference in New Issue
Block a user