From f9afa224060837fd89838f51bc2cdf8b4bb28ace Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 4 Nov 2025 18:29:28 +0800 Subject: [PATCH] =?UTF-8?q?refactor(editor):=20=E9=87=8D=E6=9E=84=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E6=9E=B6=E6=9E=84=E5=B9=B6=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E6=A0=91=E6=89=A7=E8=A1=8C=E5=8F=AF=E8=A7=86?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Runtime/BehaviorTreeData.ts | 3 + .../Runtime/BehaviorTreeExecutionSystem.ts | 5 + .../Runtime/BehaviorTreeRuntimeComponent.ts | 7 + packages/editor-app/src-tauri/src/main.rs | 33 + packages/editor-app/src/App.tsx | 96 ++- packages/editor-app/src/api/tauri.ts | 33 + .../src/application/hooks/useContextMenu.ts | 12 + .../application/hooks/useQuickCreateMenu.ts | 36 +- .../services/ExecutionController.ts | 212 +++-- .../src/components/AssetBrowser.tsx | 347 ++++++-- .../src/components/BehaviorTreeBlackboard.tsx | 2 +- .../src/components/BehaviorTreeEditor.tsx | 99 ++- .../components/BehaviorTreeNodeProperties.tsx | 81 +- .../editor-app/src/components/FileTree.tsx | 565 ++++++++++++- .../components/FlexLayoutDockContainer.tsx | 57 +- .../editor-app/src/components/Inspector.tsx | 646 +++++++++++++++ .../src/components/PromptDialog.tsx | 88 +++ .../src/plugins/BehaviorTreePlugin.ts | 167 ++-- .../connections/ConnectionRenderer.tsx | 25 +- .../behavior-tree/nodes/BehaviorTreeNode.tsx | 37 +- .../panels/BehaviorTreeEditorPanel.css | 264 +++++++ .../panels/BehaviorTreeEditorPanel.tsx | 743 ++++++++++++++++++ .../panels/BehaviorTreeNodePalettePanel.css | 8 + .../panels/BehaviorTreeNodePalettePanel.tsx | 18 + .../panels/BehaviorTreePropertiesPanel.css | 45 ++ .../panels/BehaviorTreePropertiesPanel.tsx | 381 +++++++++ .../components/behavior-tree/panels/index.ts | 2 + .../components/menu/NodeContextMenu.tsx | 75 +- .../components/menu/QuickCreateMenu.tsx | 248 ++++-- .../hooks/useCanvasMouseEvents.ts | 154 ++-- .../hooks/useExecutionController.ts | 25 +- .../src/stores/behaviorTreeStore.ts | 68 +- .../editor-app/src/styles/AssetBrowser.css | 8 + .../src/styles/BehaviorTreeNode.css | 45 +- .../editor-app/src/styles/EntityInspector.css | 153 +++- packages/editor-app/src/styles/FileTree.css | 44 +- .../editor-app/src/styles/PromptDialog.css | 153 ++++ .../src/utils/BehaviorTreeExecutor.ts | 89 ++- .../src/Plugins/EditorPluginManager.ts | 22 + .../editor-core/src/Plugins/IEditorPlugin.ts | 96 +++ .../src/Services/FileActionRegistry.ts | 110 +++ .../src/Services/WindowRegistry.ts | 180 +++++ packages/editor-core/src/Types/UITypes.ts | 5 + packages/editor-core/src/index.ts | 1 + 44 files changed, 4942 insertions(+), 546 deletions(-) create mode 100644 packages/editor-app/src/components/Inspector.tsx create mode 100644 packages/editor-app/src/components/PromptDialog.tsx create mode 100644 packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreeEditorPanel.css create mode 100644 packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreeEditorPanel.tsx create mode 100644 packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreeNodePalettePanel.css create mode 100644 packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreeNodePalettePanel.tsx create mode 100644 packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreePropertiesPanel.css create mode 100644 packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreePropertiesPanel.tsx create mode 100644 packages/editor-app/src/presentation/components/behavior-tree/panels/index.ts create mode 100644 packages/editor-app/src/styles/PromptDialog.css create mode 100644 packages/editor-core/src/Services/FileActionRegistry.ts create mode 100644 packages/editor-core/src/Services/WindowRegistry.ts diff --git a/packages/behavior-tree/src/Runtime/BehaviorTreeData.ts b/packages/behavior-tree/src/Runtime/BehaviorTreeData.ts index f5b5d63b..47f24e11 100644 --- a/packages/behavior-tree/src/Runtime/BehaviorTreeData.ts +++ b/packages/behavior-tree/src/Runtime/BehaviorTreeData.ts @@ -63,6 +63,9 @@ export interface NodeRuntimeState { /** 当前执行的子节点索引(复合节点使用) */ currentChildIndex: number; + /** 执行顺序号(用于调试和可视化) */ + executionOrder?: number; + /** 开始执行时间(某些节点需要) */ startTime?: number; diff --git a/packages/behavior-tree/src/Runtime/BehaviorTreeExecutionSystem.ts b/packages/behavior-tree/src/Runtime/BehaviorTreeExecutionSystem.ts index 794a3a79..2f5e5ce9 100644 --- a/packages/behavior-tree/src/Runtime/BehaviorTreeExecutionSystem.ts +++ b/packages/behavior-tree/src/Runtime/BehaviorTreeExecutionSystem.ts @@ -125,6 +125,11 @@ export class BehaviorTreeExecutionSystem extends EntitySystem { runtime.activeNodeIds.add(nodeData.id); state.isAborted = false; + if (state.executionOrder === undefined) { + runtime.executionOrderCounter++; + state.executionOrder = runtime.executionOrderCounter; + } + const executor = this.executorRegistry.get(nodeData.implementationType); if (!executor) { this.logger.error(`未找到执行器: ${nodeData.implementationType}`); diff --git a/packages/behavior-tree/src/Runtime/BehaviorTreeRuntimeComponent.ts b/packages/behavior-tree/src/Runtime/BehaviorTreeRuntimeComponent.ts index 23609339..e6988235 100644 --- a/packages/behavior-tree/src/Runtime/BehaviorTreeRuntimeComponent.ts +++ b/packages/behavior-tree/src/Runtime/BehaviorTreeRuntimeComponent.ts @@ -82,6 +82,12 @@ export class BehaviorTreeRuntimeComponent extends Component { @IgnoreSerialization() nodesToAbort: Set = new Set(); + /** + * 执行顺序计数器(用于调试和可视化) + */ + @IgnoreSerialization() + executionOrderCounter: number = 0; + /** * 获取节点运行时状态 */ @@ -115,6 +121,7 @@ export class BehaviorTreeRuntimeComponent extends Component { resetAllStates(): void { this.nodeStates.clear(); this.activeNodeIds.clear(); + this.executionOrderCounter = 0; } /** diff --git a/packages/editor-app/src-tauri/src/main.rs b/packages/editor-app/src-tauri/src/main.rs index 60870e29..346b4cce 100644 --- a/packages/editor-app/src-tauri/src/main.rs +++ b/packages/editor-app/src-tauri/src/main.rs @@ -54,6 +54,35 @@ fn path_exists(path: String) -> Result { 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] async fn open_project_dialog(app: AppHandle) -> Result, String> { use tauri_plugin_dialog::DialogExt; @@ -572,6 +601,10 @@ fn main() { create_directory, write_file_content, path_exists, + rename_file_or_folder, + delete_file, + delete_folder, + create_file, open_project_dialog, save_scene_dialog, open_scene_dialog, diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index c475b5ed..12fb2eec 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { Core, Scene } from '@esengine/ecs-framework'; import * as ECSFramework from '@esengine/ecs-framework'; -import { EditorPluginManager, UIRegistry, MessageHub, SerializerRegistry, EntityStoreService, ComponentRegistry, LocaleService, ProjectService, ComponentDiscoveryService, PropertyMetadataService, LogService, SettingsRegistry, SceneManagerService } from '@esengine/editor-core'; +import { 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 { SceneInspectorPlugin } from './plugins/SceneInspectorPlugin'; import { ProfilerPlugin } from './plugins/ProfilerPlugin'; @@ -9,7 +9,7 @@ import { EditorAppearancePlugin } from './plugins/EditorAppearancePlugin'; import { BehaviorTreePlugin } from './plugins/BehaviorTreePlugin'; import { StartupPage } from './components/StartupPage'; import { SceneHierarchy } from './components/SceneHierarchy'; -import { EntityInspector } from './components/EntityInspector'; +import { Inspector } from './components/Inspector'; import { AssetBrowser } from './components/AssetBrowser'; import { ConsolePanel } from './components/ConsolePanel'; import { PluginManagerWindow } from './components/PluginManagerWindow'; @@ -19,7 +19,6 @@ import { SettingsWindow } from './components/SettingsWindow'; import { AboutDialog } from './components/AboutDialog'; import { ErrorDialog } from './components/ErrorDialog'; import { ConfirmDialog } from './components/ConfirmDialog'; -import { BehaviorTreeWindow } from './components/BehaviorTreeWindow'; import { PluginGeneratorWindow } from './components/PluginGeneratorWindow'; import { ToastProvider } from './components/Toast'; import { MenuBar } from './components/MenuBar'; @@ -67,8 +66,6 @@ function App() { const [showPortManager, setShowPortManager] = useState(false); const [showSettings, setShowSettings] = useState(false); const [showAbout, setShowAbout] = useState(false); - const [showBehaviorTreeEditor, setShowBehaviorTreeEditor] = useState(false); - const [behaviorTreeFilePath, setBehaviorTreeFilePath] = useState(null); const [showPluginGenerator, setShowPluginGenerator] = useState(false); const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0); const [isRemoteConnected, setIsRemoteConnected] = useState(false); @@ -81,6 +78,7 @@ function App() { cancelText: string; onConfirm: () => void; } | null>(null); + const [activeDynamicPanels, setActiveDynamicPanels] = useState([]); useEffect(() => { // 禁用默认右键菜单 @@ -167,6 +165,7 @@ function App() { const logService = new LogService(); const settingsRegistry = new SettingsRegistry(); const sceneManagerService = new SceneManagerService(messageHub, fileAPI, projectService); + const fileActionRegistry = new FileActionRegistry(); // 监听远程日志事件 window.addEventListener('profiler:remote-log', ((event: CustomEvent) => { @@ -185,6 +184,7 @@ function App() { Core.services.registerInstance(LogService, logService); Core.services.registerInstance(SettingsRegistry, settingsRegistry); Core.services.registerInstance(SceneManagerService, sceneManagerService); + Core.services.registerInstance(FileActionRegistry, fileActionRegistry); const pluginMgr = new EditorPluginManager(); pluginMgr.initialize(coreInstance, Core.services); @@ -196,12 +196,14 @@ function App() { await pluginMgr.installEditor(new BehaviorTreePlugin()); 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); - } else if (data.windowId === 'pluginManager') { + } else if (windowId === 'pluginManager') { setShowPluginManager(true); - } else if (data.windowId === 'behavior-tree-editor') { - setShowBehaviorTreeEditor(true); } }); @@ -228,6 +230,23 @@ function App() { 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) => { try { setIsLoading(true); @@ -442,10 +461,6 @@ function App() { } }, [sceneManager, locale]); - const handleOpenBehaviorTree = useCallback((btreePath: string) => { - setBehaviorTreeFilePath(btreePath); - setShowBehaviorTreeEditor(true); - }, []); const handleSaveScene = async () => { if (!sceneManager) { @@ -544,7 +559,7 @@ function App() { { id: 'inspector', title: locale === 'zh' ? '检视器' : 'Inspector', - content: , + content: , closable: false }, { @@ -565,13 +580,13 @@ function App() { { id: 'inspector', title: locale === 'zh' ? '检视器' : 'Inspector', - content: , + content: , closable: false }, { id: 'assets', title: locale === 'zh' ? '资产' : 'Assets', - content: , + content: , closable: false }, { @@ -592,6 +607,10 @@ function App() { if (!panelDesc.component) { return false; } + // 过滤掉动态面板 + if (panelDesc.isDynamic) { + return false; + } return enabledPlugins.some((pluginName) => { const plugin = pluginManager.getEditorPlugin(pluginName); if (plugin && plugin.registerPanels) { @@ -606,15 +625,33 @@ function App() { return { id: panelDesc.id, title: (panelDesc as any).titleZh && locale === 'zh' ? (panelDesc as any).titleZh : panelDesc.title, - content: , + content: , + 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: , closable: panelDesc.closable ?? true }; }); 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) { @@ -701,7 +738,14 @@ function App() {
- + { + console.log('[App] Panel closed:', panelId); + // 从激活的动态面板列表中移除 + setActiveDynamicPanels(prev => prev.filter(id => id !== panelId)); + }} + />
@@ -748,18 +792,6 @@ function App() { setShowAbout(false)} locale={locale} /> )} - {showBehaviorTreeEditor && ( - { - setShowBehaviorTreeEditor(false); - setBehaviorTreeFilePath(null); - }} - filePath={behaviorTreeFilePath} - projectPath={currentProjectPath} - /> - )} - {showPluginGenerator && ( setShowPluginGenerator(false)} diff --git a/packages/editor-app/src/api/tauri.ts b/packages/editor-app/src/api/tauri.ts index ab485fb1..dc8d70b9 100644 --- a/packages/editor-app/src/api/tauri.ts +++ b/packages/editor-app/src/api/tauri.ts @@ -146,6 +146,39 @@ export class TauriAPI { static async scanBehaviorTrees(projectPath: string): Promise { return await invoke('scan_behavior_trees', { projectPath }); } + + /** + * 重命名文件或文件夹 + * @param oldPath 原路径 + * @param newPath 新路径 + */ + static async renameFileOrFolder(oldPath: string, newPath: string): Promise { + return await invoke('rename_file_or_folder', { oldPath, newPath }); + } + + /** + * 删除文件 + * @param path 文件路径 + */ + static async deleteFile(path: string): Promise { + return await invoke('delete_file', { path }); + } + + /** + * 删除文件夹 + * @param path 文件夹路径 + */ + static async deleteFolder(path: string): Promise { + return await invoke('delete_folder', { path }); + } + + /** + * 创建文件 + * @param path 文件路径 + */ + static async createFile(path: string): Promise { + return await invoke('create_file', { path }); + } } export interface DirectoryEntry { diff --git a/packages/editor-app/src/application/hooks/useContextMenu.ts b/packages/editor-app/src/application/hooks/useContextMenu.ts index 475bbb96..e88dff5c 100644 --- a/packages/editor-app/src/application/hooks/useContextMenu.ts +++ b/packages/editor-app/src/application/hooks/useContextMenu.ts @@ -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 = () => { setContextMenu({ ...contextMenu, visible: false }); }; @@ -38,6 +49,7 @@ export function useContextMenu() { contextMenu, setContextMenu, handleNodeContextMenu, + handleCanvasContextMenu, closeContextMenu }; } diff --git a/packages/editor-app/src/application/hooks/useQuickCreateMenu.ts b/packages/editor-app/src/application/hooks/useQuickCreateMenu.ts index b239ce93..83d5c3fc 100644 --- a/packages/editor-app/src/application/hooks/useQuickCreateMenu.ts +++ b/packages/editor-app/src/application/hooks/useQuickCreateMenu.ts @@ -131,11 +131,6 @@ export function useQuickCreateMenu(params: UseQuickCreateMenuParams) { return; } - // 创建模式:需要连接 - if (!connectingFrom) { - return; - } - const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) { return; @@ -150,20 +145,23 @@ export function useQuickCreateMenu(params: UseQuickCreateMenuParams) { template.defaultConfig ); - const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom); - if (fromNode) { - if (connectingFromProperty) { - // 属性连接 - connectionOperations.addConnection( - connectingFrom, - newNode.id, - 'property', - connectingFromProperty, - undefined - ); - } else { - // 节点连接 - connectionOperations.addConnection(connectingFrom, newNode.id, 'node'); + // 如果有连接源,创建连接 + if (connectingFrom) { + const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom); + if (fromNode) { + if (connectingFromProperty) { + // 属性连接 + connectionOperations.addConnection( + connectingFrom, + newNode.id, + 'property', + connectingFromProperty, + undefined + ); + } else { + // 节点连接 + connectionOperations.addConnection(connectingFrom, newNode.id, 'node'); + } } } diff --git a/packages/editor-app/src/application/services/ExecutionController.ts b/packages/editor-app/src/application/services/ExecutionController.ts index 20b206dd..6c7eb048 100644 --- a/packages/editor-app/src/application/services/ExecutionController.ts +++ b/packages/editor-app/src/application/services/ExecutionController.ts @@ -1,12 +1,11 @@ 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 { DOMCache } from '../../presentation/utils/DOMCache'; import { EditorEventBus, EditorEvent } from '../../infrastructure/events/EditorEventBus'; import { ExecutionHooksManager } from '../interfaces/IExecutionHooks'; export type ExecutionMode = 'idle' | 'running' | 'paused' | 'step'; -type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure'; type BlackboardVariables = Record; interface ExecutionControllerConfig { @@ -15,6 +14,7 @@ interface ExecutionControllerConfig { onLogsUpdate: (logs: ExecutionLog[]) => void; onBlackboardUpdate: (variables: BlackboardVariables) => void; onTickCountUpdate: (count: number) => void; + onExecutionStatusUpdate: (statuses: Map, orders: Map) => void; eventBus?: EditorEventBus; hooksManager?: ExecutionHooksManager; } @@ -36,6 +36,12 @@ export class ExecutionController { private currentConnections: Connection[] = []; 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) { this.config = config; this.executor = new BehaviorTreeExecutor(); @@ -57,6 +63,7 @@ export class ExecutionController { setSpeed(speed: number): void { this.speed = speed; + this.lastTickTime = 0; } async play( @@ -156,23 +163,14 @@ export class ExecutionController { this.mode = 'idle'; this.tickCount = 0; this.lastTickTime = 0; + this.lastStepTime = 0; + this.pendingStatusUpdates = []; + this.currentlyDisplayedIndex = 0; this.domCache.clearAllStatusTimers(); this.domCache.clearStatusCache(); - this.domCache.forEachNode((node) => { - 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'); - }); + this.config.onExecutionStatusUpdate(new Map(), new Map()); if (this.animationFrameId !== null) { cancelAnimationFrame(this.animationFrameId); @@ -217,6 +215,24 @@ export class ExecutionController { 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 { this.domCache.clearAll(); } @@ -239,21 +255,96 @@ export class ExecutionController { 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 tickInterval = baseTickInterval / this.speed; + const scaledTickInterval = baseTickInterval / this.speed; - if (this.lastTickTime === 0 || (currentTime - this.lastTickTime) >= tickInterval) { - const deltaTime = 0.016; + if (this.lastTickTime === 0) { + 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.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(); + const orderMap = new Map(); + + 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( @@ -267,52 +358,49 @@ export class ExecutionController { this.config.onBlackboardUpdate(runtimeBlackboardVars); } - const statusMap: Record = {}; + if (this.stepByStepMode) { + const statusesWithOrder = statuses.filter(s => s.executionOrder !== undefined); - statuses.forEach((s) => { - statusMap[s.nodeId] = s.status; + if (statusesWithOrder.length > 0) { + const minOrder = Math.min(...statusesWithOrder.map(s => s.executionOrder!)); - if (!this.domCache.hasStatusChanged(s.nodeId, s.status)) { - return; + 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)); + } + } } - this.domCache.setLastStatus(s.nodeId, s.status); + } else { + const statusMap = new Map(); + const orderMap = new Map(); - const nodeElement = this.domCache.getNode(s.nodeId); - if (!nodeElement) { - return; - } + statuses.forEach((s) => { + statusMap.set(s.nodeId, s.status); + if (s.executionOrder !== undefined) { + orderMap.set(s.nodeId, s.executionOrder); + } + }); - 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( diff --git a/packages/editor-app/src/components/AssetBrowser.tsx b/packages/editor-app/src/components/AssetBrowser.tsx index 00608a00..aef645f8 100644 --- a/packages/editor-app/src/components/AssetBrowser.tsx +++ b/packages/editor-app/src/components/AssetBrowser.tsx @@ -1,7 +1,7 @@ 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 { MessageHub } from '@esengine/editor-core'; +import { MessageHub, FileActionRegistry } from '@esengine/editor-core'; import { TauriAPI, DirectoryEntry } from '../api/tauri'; import { FileTree } from './FileTree'; import { ResizablePanel } from './ResizablePanel'; @@ -13,21 +13,30 @@ interface AssetItem { path: string; type: 'file' | 'folder'; extension?: string; + size?: number; + modified?: number; } interface AssetBrowserProps { projectPath: string | null; locale: string; 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(null); const [selectedPath, setSelectedPath] = useState(null); const [assets, setAssets] = useState([]); const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = 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<{ position: { x: number; y: number }; asset: AssetItem; @@ -105,7 +114,9 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT name: entry.name, path: entry.path, 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 => { + 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) => { setCurrentPath(path); loadAssets(path); @@ -128,6 +206,17 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT const handleAssetClick = (asset: AssetItem) => { 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) => { @@ -137,15 +226,25 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT } else if (asset.type === 'file') { if (asset.extension === 'ecs' && onOpenScene) { onOpenScene(asset.path); - } else if (asset.extension === 'btree' && onOpenBehaviorTree) { - onOpenBehaviorTree(asset.path); - } else { - // 其他文件使用系统默认程序打开 - try { - await TauriAPI.openFileWithSystemApp(asset.path); - } catch (error) { - console.error('Failed to open file:', error); + return; + } + + 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 { + console.log('[AssetBrowser] FileActionRegistry not available'); + } + + try { + await TauriAPI.openFileWithSystemApp(asset.path); + } catch (error) { + console.error('Failed to open file:', error); } } }; @@ -168,6 +267,27 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT icon: , 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; }; - const filteredAssets = searchQuery - ? assets.filter((asset) => - asset.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) - : assets; + const filteredAssets = searchQuery.trim() ? searchResults : assets; + + const getRelativePath = (fullPath: string): string => { + if (!projectPath) return fullPath; + const relativePath = fullPath.replace(projectPath, '').replace(/^[/\\]/, ''); + const parts = relativePath.split(/[/\\]/); + return parts.slice(0, -1).join('/'); + }; const getFileIcon = (asset: AssetItem) => { if (asset.type === 'folder') { @@ -289,26 +412,102 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT return (
-
-

{t.title}

-
-
- - -
- } - rightOrBottom={ +
+
+ + +
+ setSearchQuery(e.target.value)} + style={{ + flex: 1, + padding: '6px 10px', + background: '#3c3c3c', + border: '1px solid #3e3e3e', + borderRadius: '3px', + color: '#cccccc', + fontSize: '12px', + outline: 'none' + }} + /> +
+ {showDetailView ? ( + + +
+ } + rightOrBottom={
{breadcrumbs.map((crumb, index) => ( @@ -326,47 +525,65 @@ export function AssetBrowser({ projectPath, locale, onOpenScene, onOpenBehaviorT ))}
-
- setSearchQuery(e.target.value)} - /> -
- {loading ? ( + {(loading || isSearching) ? (
-

{t.loading}

+

{isSearching ? '搜索中...' : t.loading}

) : filteredAssets.length === 0 ? (
-

{t.empty}

+

{searchQuery.trim() ? '未找到匹配的资产' : t.empty}

) : (
- {filteredAssets.map((asset, index) => ( -
handleAssetClick(asset)} - onDoubleClick={() => handleAssetDoubleClick(asset)} - onContextMenu={(e) => handleContextMenu(e, asset)} - > - {getFileIcon(asset)} -
- {asset.name} + {filteredAssets.map((asset, index) => { + const relativePath = getRelativePath(asset.path); + const showPath = searchQuery.trim() && relativePath; + return ( +
handleAssetClick(asset)} + onDoubleClick={() => handleAssetDoubleClick(asset)} + onContextMenu={(e) => handleContextMenu(e, asset)} + > + {getFileIcon(asset)} +
+
+ {asset.name} +
+ {showPath && ( +
+ {relativePath} +
+ )} +
+
+ {asset.type === 'folder' ? t.folder : (asset.extension || t.file)} +
-
- {asset.type === 'folder' ? t.folder : (asset.extension || t.file)} -
-
- ))} + ); + })}
)}
} /> + ) : ( +
+ +
+ )}
{contextMenu && ( = ({ {/* 标题栏 */}
void; blackboardVariables?: BlackboardVariables; projectPath?: string | null; + showToolbar?: boolean; } export const BehaviorTreeEditor: React.FC = ({ onNodeSelect, onNodeCreate, blackboardVariables = {}, - projectPath = null + projectPath = null, + showToolbar = true }) => { const { showToast } = useToast(); @@ -75,7 +76,11 @@ export const BehaviorTreeEditor: React.FC = ({ setInitialBlackboardVariables, setIsExecuting, initialBlackboardVariables, - isExecuting + isExecuting, + saveNodesDataSnapshot, + restoreNodesData, + nodeExecutionStatuses, + nodeExecutionOrders } = useBehaviorTreeStore(); // UI store(选中、拖拽、画布状态) @@ -107,7 +112,7 @@ export const BehaviorTreeEditor: React.FC = ({ const connectionOperations = useConnectionOperations(validator, commandManager); // 右键菜单 - const { contextMenu, setContextMenu, handleNodeContextMenu, closeContextMenu } = useContextMenu(); + const { contextMenu, setContextMenu, handleNodeContextMenu, handleCanvasContextMenu, closeContextMenu } = useContextMenu(); // 组件挂载和连线变化时强制更新,确保连线能正确渲染 useEffect(() => { @@ -164,7 +169,9 @@ export const BehaviorTreeEditor: React.FC = ({ initialBlackboardVariables, onBlackboardUpdate: setBlackboardVariables, onInitialBlackboardSave: setInitialBlackboardVariables, - onExecutingChange: setIsExecuting + onExecutingChange: setIsExecuting, + onSaveNodesDataSnapshot: saveNodesDataSnapshot, + onRestoreNodesData: restoreNodesData }); executorRef.current = controller['executor'] || null; @@ -296,7 +303,8 @@ export const BehaviorTreeEditor: React.FC = ({ setSelectedConnection, setQuickCreateMenu, clearConnecting, - clearBoxSelect + clearBoxSelect, + showToast }); @@ -373,6 +381,7 @@ export const BehaviorTreeEditor: React.FC = ({ handleNodeMouseUp(); handleCanvasMouseUp(e); }} + onContextMenu={handleCanvasContextMenu} > {/* 连接线层 */} = ({ {nodes.map((node: BehaviorTreeNode) => { const isSelected = selectedNodeIds.includes(node.id); const isBeingDragged = dragStartPositions.has(node.id); + const executionStatus = nodeExecutionStatuses.get(node.id); + const executionOrder = nodeExecutionOrders.get(node.id); return ( = ({ blackboardVariables={blackboardVariables} initialBlackboardVariables={initialBlackboardVariables} isExecuting={isExecuting} + executionStatus={executionStatus} + executionOrder={executionOrder} connections={connections} nodes={nodes} executorRef={executorRef} @@ -543,20 +556,22 @@ export const BehaviorTreeEditor: React.FC = ({ {/* 运行控制工具栏 */} - + {showToolbar && ( + + )} {/* 快速创建菜单 */} = ({ selectedIndex={quickCreateMenu.selectedIndex} mode={quickCreateMenu.mode} iconMap={ICON_MAP} - onSearchChange={(text) => setQuickCreateMenu({ - ...quickCreateMenu, + onSearchChange={(text) => setQuickCreateMenu(prev => ({ + ...prev, searchText: text - })} - onIndexChange={(index) => setQuickCreateMenu({ - ...quickCreateMenu, + }))} + onIndexChange={(index) => setQuickCreateMenu(prev => ({ + ...prev, selectedIndex: index - })} + }))} onNodeSelect={handleQuickCreateNode} onClose={() => { setQuickCreateMenu({ @@ -615,21 +630,6 @@ export const BehaviorTreeEditor: React.FC = ({
- {/* 执行面板 */} -
- setExecutionLogs([])} - isRunning={executionMode === 'running'} - tickCount={tickCount} - executionSpeed={executionSpeed} - onSpeedChange={handleSpeedChange} - /> -
- {/* 右键菜单 */} = ({ }); 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 }); + }} />
); diff --git a/packages/editor-app/src/components/BehaviorTreeNodeProperties.tsx b/packages/editor-app/src/components/BehaviorTreeNodeProperties.tsx index f4a21c11..e09743e1 100644 --- a/packages/editor-app/src/components/BehaviorTreeNodeProperties.tsx +++ b/packages/editor-app/src/components/BehaviorTreeNodeProperties.tsx @@ -39,6 +39,13 @@ export const BehaviorTreeNodeProperties: React.FC(null); + const [isComposing, setIsComposing] = useState(false); + const [localValues, setLocalValues] = useState>({}); + + // 当节点切换时,清空本地状态 + React.useEffect(() => { + setLocalValues({}); + }, [selectedNode?.template.className]); if (!selectedNode) { return ( @@ -58,11 +65,31 @@ export const BehaviorTreeNodeProperties: React.FC { + 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); }; 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) { case 'string': @@ -71,7 +98,10 @@ export const BehaviorTreeNodeProperties: React.FC 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} style={{ width: '100%', @@ -148,7 +178,10 @@ export const BehaviorTreeNodeProperties: React.FC 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} rows={5} style={{ @@ -171,7 +204,10 @@ export const BehaviorTreeNodeProperties: React.FC 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="黑板变量名" style={{ flex: 1, @@ -340,43 +376,6 @@ export const BehaviorTreeNodeProperties: React.FC - {/* 操作按钮 */} -
- - -
- {/* 资产选择器对话框 */} {assetPickerOpen && projectPath && assetPickerProperty && ( void; 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([]); const [loading, setLoading] = useState(false); + const [internalSelectedPath, setInternalSelectedPath] = useState(null); + const [contextMenu, setContextMenu] = useState<{ + position: { x: number; y: number }; + node: TreeNode | null; + } | null>(null); + const [renamingNode, setRenamingNode] = useState(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; + } | null>(null); + const [filteredTree, setFilteredTree] = useState([]); + const fileActionRegistry = Core.services.resolve(FileActionRegistry); useEffect(() => { if (rootPath) { @@ -30,6 +56,74 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps } }, [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 => { + 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) => { setLoading(true); try { @@ -57,16 +151,20 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps }; const entriesToNodes = (entries: DirectoryEntry[]): TreeNode[] => { - // 只显示文件夹,过滤掉文件 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) => ({ name: entry.name, path: entry.path, - type: 'folder' as const, - children: [], + type: entry.is_dir ? 'folder' as const : 'file' as const, + size: entry.size, + modified: entry.modified, + children: entry.is_dir ? [] : undefined, expanded: false, - loaded: false + loaded: entry.is_dir ? false : undefined })); }; @@ -115,13 +213,343 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps setTree(newTree); }; + const refreshTree = async () => { + if (rootPath) { + await loadRootDirectory(rootPath); + } + }; + + const expandAll = async () => { + const expandNode = async (node: TreeNode): Promise => { + 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: , + onClick: () => rootPath && handleCreateFileClick(rootPath) + }, + { + label: '新建文件夹', + icon: , + 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: , + 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: , + onClick: () => { + setRenamingNode(node.path); + setNewName(node.name); + } + }); + + items.push({ + label: '删除', + icon: , + onClick: () => handleDeleteClick(node) + }); + + items.push({ label: '', separator: true, onClick: () => {} }); + + if (node.type === 'folder') { + items.push({ + label: '新建文件', + icon: , + onClick: () => handleCreateFileClick(node.path) + }); + + items.push({ + label: '新建文件夹', + icon: , + 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: , + onClick: async () => { + try { + await TauriAPI.showInFolder(node.path); + } catch (error) { + console.error('Failed to show in folder:', error); + } + } + }); + + items.push({ + label: '复制路径', + icon: , + onClick: () => { + navigator.clipboard.writeText(node.path); + } + }); + + return items; + }; + const handleNodeClick = (node: TreeNode) => { - onSelectFile?.(node.path); - toggleNode(node.path); + if (node.type === 'folder') { + setInternalSelectedPath(node.path); + onSelectFile?.(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 isSelected = selectedPath === node.path; + const isSelected = (internalSelectedPath || selectedPath) === node.path; + const isRenaming = renamingNode === node.path; const indent = level * 16; return ( @@ -129,17 +557,46 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps
handleNodeClick(node)} + onClick={() => !isRenaming && handleNodeClick(node)} + onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)} + onContextMenu={(e) => handleContextMenu(e, node)} > - {node.expanded ? : } + {node.type === 'folder' ? ( + node.expanded ? : + ) : ( + + )} - + {node.type === 'folder' ? ( + + ) : ( + + )} - {node.name} + {isRenaming ? ( + 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()} + /> + ) : ( + {node.name} + )}
- {node.expanded && node.children && ( + {node.type === 'folder' && node.expanded && node.children && (
{node.children.map((child) => renderNode(child, level + 1))}
@@ -157,8 +614,78 @@ export function FileTree({ rootPath, onSelectFile, selectedPath }: FileTreeProps } return ( -
- {tree.map((node) => renderNode(node))} -
+ <> +
+ + +
+
{ + const target = e.target as HTMLElement; + if (target.classList.contains('file-tree')) { + handleContextMenu(e, null); + } + }} + > + {filteredTree.map((node) => renderNode(node))} +
+ {contextMenu && ( + setContextMenu(null)} + /> + )} + {deleteDialog && ( + setDeleteDialog(null)} + /> + )} + {promptDialog && ( + setPromptDialog(null)} + /> + )} + ); } diff --git a/packages/editor-app/src/components/FlexLayoutDockContainer.tsx b/packages/editor-app/src/components/FlexLayoutDockContainer.tsx index bf425880..a217fb4f 100644 --- a/packages/editor-app/src/components/FlexLayoutDockContainer.tsx +++ b/packages/editor-app/src/components/FlexLayoutDockContainer.tsx @@ -17,17 +17,12 @@ interface FlexLayoutDockContainerProps { export function FlexLayoutDockContainer({ panels, onPanelClose }: FlexLayoutDockContainerProps) { 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 bottomPanels = panels.filter((p) => p.id.includes('console') || p.id.includes('asset')) - .sort((a, b) => { - // 控制台排在前面 - if (a.id.includes('console')) return -1; - if (b.id.includes('console')) return 1; - return 0; - }); + const bottomPanels = panels.filter((p) => p.id.includes('console')); 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 @@ -61,17 +56,43 @@ export function FlexLayoutDockContainer({ panels, onPanelClose }: FlexLayoutDock // Build main row children const mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[] = []; - if (leftPanels.length > 0) { + + // 左侧列:场景层级和资产面板垂直排列(五五分) + if (hierarchyPanels.length > 0 || assetPanels.length > 0) { + const leftColumnChildren: IJsonTabSetNode[] = []; + + if (hierarchyPanels.length > 0) { + leftColumnChildren.push({ + type: 'tabset', + weight: 50, + children: hierarchyPanels.map((p) => ({ + type: 'tab', + name: p.title, + id: p.id, + component: p.id, + enableClose: p.closable !== false + })) + }); + } + + 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: 'tabset', + type: 'row', weight: 20, - children: leftPanels.map((p) => ({ - type: 'tab', - name: p.title, - id: p.id, - component: p.id, - enableClose: p.closable !== false - })) + children: leftColumnChildren }); } if (centerColumnChildren.length > 0) { diff --git a/packages/editor-app/src/components/Inspector.tsx b/packages/editor-app/src/components/Inspector.tsx new file mode 100644 index 00000000..5c53c4ce --- /dev/null +++ b/packages/editor-app/src/components/Inspector.tsx @@ -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(null); + const [expandedComponents, setExpandedComponents] = useState>(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 ( +
+ + null +
+ ); + } + + if (typeof value === 'object' && !Array.isArray(value)) { + return ( +
+ +
+ {Object.entries(value).map(([subKey, subValue]) => ( +
+ {subKey}: + {String(subValue)} +
+ ))} +
+
+ ); + } + + return ( +
+ + {String(value)} +
+ ); + }; + + 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 ( +
+
+ + {fileInfo.name} +
+ +
+
+
文件信息
+
+ + + {fileInfo.isDirectory ? '文件夹' : fileInfo.extension ? `.${fileInfo.extension}` : '文件'} + +
+ {fileInfo.size !== undefined && !fileInfo.isDirectory && ( +
+ + {formatFileSize(fileInfo.size)} +
+ )} + {fileInfo.modified !== undefined && ( +
+ + {formatDate(fileInfo.modified)} +
+ )} +
+ + + {fileInfo.path} + +
+
+ + {content && ( +
+
文件预览
+
+ {content} +
+
+ )} + + {!content && !fileInfo.isDirectory && ( +
+
+ 此文件类型不支持预览 +
+
+ )} +
+
+ ); + }; + + const renderBehaviorTreeNode = (node: BehaviorTreeNode) => { + const IconComponent = node.template.icon ? (ICON_MAP as any)[node.template.icon] : Box; + + return ( +
+
+ {IconComponent && } + {node.template.displayName || '未命名节点'} + +
+ + {isRunning && ( +
+ + 运行时模式:属性修改将在停止后还原 +
+ )} + +
+
+
基本信息
+
+ + {node.template.type} +
+
+ + {node.template.category} +
+ {node.template.description && ( +
+ + {node.template.description} +
+ )} + {node.template.className && ( +
+ + + {node.template.className} + +
+ )} +
+ + {node.template.properties && node.template.properties.length > 0 && ( +
+
属性
+ +
+ )} + + {node.children.length > 0 && ( +
+
子节点 ({node.children.length})
+
+ {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 ( +
+ {index + 1}. + {childNode && ChildIcon && ( + + )} + + {childNode?.template.displayName || childId} + +
+ ); + })} +
+
+ )} + +
+
调试信息
+
+ + + {node.id} + +
+
+ + + ({node.position.x.toFixed(0)}, {node.position.y.toFixed(0)}) + +
+
+
+
+ ); + }; + + if (!target) { + return ( +
+
+ +
未选择对象
+
+ 选择实体或节点以查看详细信息 +
+
+
+ ); + } + + 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 ( +
+
+ + 运行时实体 #{entity.id} +
+ +
+
+
基本信息
+
+ + {entity.id} +
+
+ + {entity.enabled ? 'true' : 'false'} +
+ {entity.name && ( +
+ + {entity.name} +
+ )} +
+ + {details && ( +
+
组件详情
+ {Object.entries(details).map(([key, value]) => renderRemoteProperty(key, value))} +
+ )} +
+
+ ); + } + + if (target.type === 'entity') { + const entity = target.data; + + return ( +
+
+ + {entity.name || `Entity #${entity.id}`} +
+ +
+
+
基本信息
+
+ + {entity.id} +
+
+ + {entity.enabled ? 'true' : 'false'} +
+
+ + {entity.components.length > 0 && ( +
+
组件
+ {entity.components.map((component: any, index: number) => { + const isExpanded = expandedComponents.has(index); + const componentName = component.constructor?.name || 'Component'; + + return ( +
+
toggleComponentExpanded(index)}> + {isExpanded ? : } + {componentName} + +
+ + {isExpanded && ( +
+ handlePropertyChange(component, propName, value)} + /> +
+ )} +
+ ); + })} +
+ )} +
+
+ ); + } + + return null; +} diff --git a/packages/editor-app/src/components/PromptDialog.tsx b/packages/editor-app/src/components/PromptDialog.tsx new file mode 100644 index 00000000..1655ee56 --- /dev/null +++ b/packages/editor-app/src/components/PromptDialog.tsx @@ -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(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 ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+
+

{message}

+ setValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + /> +
+
+ + +
+
+
+ ); +} diff --git a/packages/editor-app/src/plugins/BehaviorTreePlugin.ts b/packages/editor-app/src/plugins/BehaviorTreePlugin.ts index bb18370e..f24b4e54 100644 --- a/packages/editor-app/src/plugins/BehaviorTreePlugin.ts +++ b/packages/editor-app/src/plugins/BehaviorTreePlugin.ts @@ -1,7 +1,12 @@ import type { Core, ServiceContainer } from '@esengine/ecs-framework'; import { IEditorPlugin, EditorPluginCategory, PanelPosition, MessageHub } from '@esengine/editor-core'; -import type { MenuItem, ToolbarItem, PanelDescriptor, ISerializer } from '@esengine/editor-core'; +import type { MenuItem, ToolbarItem, PanelDescriptor, ISerializer, FileActionHandler, FileCreationTemplate, FileContextMenuItem } from '@esengine/editor-core'; 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[] { - 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 - } - ]; + return []; } registerToolbar(): ToolbarItem[] { - return [ - { - id: 'toolbar-new-behavior-tree', - label: 'New Behavior Tree', - groupId: 'behavior-tree-tools', - icon: 'FilePlus', - onClick: () => this.createNewBehaviorTree(), - order: 10 - }, - { - id: 'toolbar-save-behavior-tree', - label: 'Save Behavior Tree', - groupId: 'behavior-tree-tools', - icon: 'Save', - onClick: () => this.saveBehaviorTree(), - order: 20 - }, - { - id: 'toolbar-validate-behavior-tree', - label: 'Validate Behavior Tree', - groupId: 'behavior-tree-tools', - icon: 'CheckCircle', - onClick: () => this.validateBehaviorTree(), - order: 30 - } - ]; + return []; } registerPanels(): PanelDescriptor[] { return [ { - id: 'panel-behavior-tree-editor', - title: 'Behavior Tree Editor', - position: PanelPosition.Center, - resizable: true, - closable: true, + id: 'behavior-tree-editor', + title: '行为树编辑器', icon: 'Network', - order: 10 - }, - { - id: 'panel-behavior-tree-nodes', - title: 'Behavior Tree Nodes', - position: PanelPosition.Left, - defaultSize: 250, - resizable: true, + component: BehaviorTreeEditorPanel, + position: PanelPosition.Center, + defaultSize: 400, closable: true, - icon: 'Package', - order: 20 - }, - { - id: 'panel-behavior-tree-properties', - title: 'Node Properties', - position: PanelPosition.Right, - defaultSize: 300, - resizable: true, - closable: true, - icon: 'Settings', - order: 20 + isDynamic: true } ]; } @@ -155,20 +105,85 @@ export class BehaviorTreePlugin implements IEditorPlugin { } } - private createNewBehaviorTree(): void { - console.log('[BehaviorTreePlugin] Creating new behavior tree'); + registerFileActionHandlers(): FileActionHandler[] { + 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 { - console.log('[BehaviorTreePlugin] Opening behavior tree'); - } - - private saveBehaviorTree(): void { - console.log('[BehaviorTreePlugin] Saving behavior tree'); - } - - private validateBehaviorTree(): void { - console.log('[BehaviorTreePlugin] Validating behavior tree'); + registerFileCreationTemplates(): FileCreationTemplate[] { + 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 serializeBehaviorTreeData(treeData: BehaviorTreeData): string { diff --git a/packages/editor-app/src/presentation/components/behavior-tree/connections/ConnectionRenderer.tsx b/packages/editor-app/src/presentation/components/behavior-tree/connections/ConnectionRenderer.tsx index b256ba2c..0b80daed 100644 --- a/packages/editor-app/src/presentation/components/behavior-tree/connections/ConnectionRenderer.tsx +++ b/packages/editor-app/src/presentation/components/behavior-tree/connections/ConnectionRenderer.tsx @@ -97,6 +97,7 @@ export const ConnectionRenderer: React.FC = ({ const color = connection.connectionType === 'property' ? '#9c27b0' : '#0e639c'; const strokeColor = isSelected ? '#FFD700' : color; const strokeWidth = isSelected ? 4 : 2; + const markerId = `arrowhead-${connection.from}-${connection.to}`; if (!pathData) { // DOM还没渲染完成,跳过此连接 @@ -130,20 +131,10 @@ export const ConnectionRenderer: React.FC = ({ strokeWidth={20} /> - {/* 实际显示的线条 */} - - - {/* 箭头标记 */} + {/* 箭头标记定义 */} = ({ + {/* 实际显示的线条 */} + + {/* 选中时显示的中点 */} {isSelected && ( ; @@ -44,6 +46,8 @@ export const BehaviorTreeNode: React.FC = ({ blackboardVariables, initialBlackboardVariables, isExecuting, + executionStatus, + executionOrder, connections, nodes, executorRef, @@ -67,7 +71,8 @@ export const BehaviorTreeNode: React.FC = ({ 'bt-node', isSelected && 'selected', isRoot && 'root', - isUncommitted && 'uncommitted' + isUncommitted && 'uncommitted', + executionStatus && executionStatus !== 'idle' && executionStatus ].filter(Boolean).join(' '); return ( @@ -162,11 +167,33 @@ export const BehaviorTreeNode: React.FC = ({ #{node.id} + {executionOrder !== undefined && ( +
+ {executionOrder} +
+ )} {!isRoot && node.template.className && executorRef.current && !executorRef.current.hasExecutor(node.template.className) && (
= ({
= ({
= ({ 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(null); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [projectPath, setProjectPath] = useState(''); + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); + const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); + const [availableBTreeFiles, setAvailableBTreeFiles] = useState([]); + const [isBlackboardOpen, setIsBlackboardOpen] = useState(true); + const [globalVariables, setGlobalVariables] = useState>({}); + 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('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 = {}; + 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('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('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 = {}; + 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('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('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 => { + try { + const sourceFilePath = `${projectPath}/.ecs/behaviors/${assetId}.btree`; + const editorJson = await invoke('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('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 = {}; + 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 = {}; + 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 = {}; + allVars.forEach((v) => { + varsObject[v.name] = v.value; + }); + setGlobalVariables(varsObject); + setHasUnsavedGlobalChanges(true); + }; + + if (!isOpen) { + return null; + } + + return ( +
+
+ {/* 文件操作 */} +
+ + + +
+ +
+ + {/* 执行控制 */} +
+ {executionMode === 'idle' || executionMode === 'step' ? ( + + ) : executionMode === 'paused' ? ( + + ) : ( + + )} + + + +
+ 速率: + handleSpeedChange(parseFloat(e.target.value))} + className="speed-slider" + title={`执行速率: ${executionSpeed.toFixed(1)}x`} + /> + {executionSpeed.toFixed(1)}x +
+
+ +
+ + {/* 视图控制 */} +
+ + +
+ + {/* 文件名 */} +
+ + {currentFilePath + ? `${currentFilePath.split(/[\\/]/).pop()?.replace('.btree', '')}${hasUnsavedChanges ? ' *' : ''}` + : t('behaviorTree.title') + } + +
+
+ +
+
+ { + messageHub?.publish('behavior-tree:node-selected', { node }); + }} + onNodeCreate={(_template, _position) => { + // Node created + }} + blackboardVariables={blackboardVariables} + projectPath={projectPath || propProjectPath || null} + showToolbar={false} + /> +
+ + {isBlackboardOpen && ( +
+
+ {t('behaviorTree.blackboard')} + +
+
+ +
+
+ )} + + {!isBlackboardOpen && ( + + )} +
+ + setIsExportDialogOpen(false)} + onExport={handleDoExport} + hasProject={!!projectPath} + availableFiles={availableBTreeFiles} + currentFileName={currentFilePath ? currentFilePath.split(/[\\/]/).pop()?.replace('.btree', '') : undefined} + projectPath={projectPath} + /> + + setIsSaveDialogOpen(false)} + /> +
+ ); +}; diff --git a/packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreeNodePalettePanel.css b/packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreeNodePalettePanel.css new file mode 100644 index 00000000..78581497 --- /dev/null +++ b/packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreeNodePalettePanel.css @@ -0,0 +1,8 @@ +.behavior-tree-node-palette-panel { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + background: var(--bg-secondary); + overflow: hidden; +} diff --git a/packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreeNodePalettePanel.tsx b/packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreeNodePalettePanel.tsx new file mode 100644 index 00000000..05e52328 --- /dev/null +++ b/packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreeNodePalettePanel.tsx @@ -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 ( +
+ { + messageHub?.publish('behavior-tree:node-palette-selected', { template }); + }} + /> +
+ ); +}; diff --git a/packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreePropertiesPanel.css b/packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreePropertiesPanel.css new file mode 100644 index 00000000..99245bed --- /dev/null +++ b/packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreePropertiesPanel.css @@ -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; +} diff --git a/packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreePropertiesPanel.tsx b/packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreePropertiesPanel.tsx new file mode 100644 index 00000000..224c2982 --- /dev/null +++ b/packages/editor-app/src/presentation/components/behavior-tree/panels/BehaviorTreePropertiesPanel.tsx @@ -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 = ({ 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; + } | undefined>(); + + const [activeTab, setActiveTab] = useState<'properties' | 'blackboard'>('blackboard'); + const [projectPath, setProjectPath] = useState(''); + const [globalVariables, setGlobalVariables] = useState>({}); + 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 = {}; + 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('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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + 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 ( +
+
+ + +
+ +
+ {activeTab === 'properties' ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/packages/editor-app/src/presentation/components/behavior-tree/panels/index.ts b/packages/editor-app/src/presentation/components/behavior-tree/panels/index.ts new file mode 100644 index 00000000..04f5a096 --- /dev/null +++ b/packages/editor-app/src/presentation/components/behavior-tree/panels/index.ts @@ -0,0 +1,2 @@ +export { BehaviorTreeEditorPanel } from './BehaviorTreeEditorPanel'; +export { BehaviorTreeNodePalettePanel } from './BehaviorTreeNodePalettePanel'; diff --git a/packages/editor-app/src/presentation/components/menu/NodeContextMenu.tsx b/packages/editor-app/src/presentation/components/menu/NodeContextMenu.tsx index 5c53544e..ce52ddf5 100644 --- a/packages/editor-app/src/presentation/components/menu/NodeContextMenu.tsx +++ b/packages/editor-app/src/presentation/components/menu/NodeContextMenu.tsx @@ -1,19 +1,36 @@ import React from 'react'; +import { Trash2, Replace, Plus } from 'lucide-react'; interface NodeContextMenuProps { visible: boolean; position: { x: number; y: number }; nodeId: string | null; - onReplaceNode: () => void; + onReplaceNode?: () => void; + onDeleteNode?: () => void; + onCreateNode?: () => void; } export const NodeContextMenu: React.FC = ({ visible, position, - onReplaceNode + nodeId, + onReplaceNode, + onDeleteNode, + onCreateNode }) => { 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 (
= ({ }} onClick={(e) => e.stopPropagation()} > -
e.currentTarget.style.backgroundColor = '#094771'} - onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} - > - 替换节点 -
+ {nodeId ? ( + <> + {onReplaceNode && ( +
e.currentTarget.style.backgroundColor = '#094771'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + + 替换节点 +
+ )} + {onDeleteNode && ( +
e.currentTarget.style.backgroundColor = '#5a1a1a'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + + 删除节点 +
+ )} + + ) : ( + <> + {onCreateNode && ( +
e.currentTarget.style.backgroundColor = '#094771'} + onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'} + > + + 新建节点 +
+ )} + + )}
); }; diff --git a/packages/editor-app/src/presentation/components/menu/QuickCreateMenu.tsx b/packages/editor-app/src/presentation/components/menu/QuickCreateMenu.tsx index 55538dcd..1e8710ca 100644 --- a/packages/editor-app/src/presentation/components/menu/QuickCreateMenu.tsx +++ b/packages/editor-app/src/presentation/components/menu/QuickCreateMenu.tsx @@ -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 { Search, X, LucideIcon } from 'lucide-react'; +import { Search, X, LucideIcon, ChevronDown, ChevronRight } from 'lucide-react'; interface QuickCreateMenuProps { visible: boolean; @@ -15,6 +15,12 @@ interface QuickCreateMenuProps { onClose: () => void; } +interface CategoryGroup { + category: string; + templates: NodeTemplate[]; + isExpanded: boolean; +} + export const QuickCreateMenu: React.FC = ({ visible, position, @@ -27,6 +33,8 @@ export const QuickCreateMenu: React.FC = ({ onClose }) => { const selectedNodeRef = useRef(null); + const [expandedCategories, setExpandedCategories] = useState>(new Set()); + const [shouldAutoScroll, setShouldAutoScroll] = useState(false); const allTemplates = NodeTemplates.getAllTemplates(); const searchTextLower = searchText.toLowerCase(); @@ -40,17 +48,63 @@ export const QuickCreateMenu: React.FC = ({ }) : allTemplates; + const categoryGroups: CategoryGroup[] = React.useMemo(() => { + const groups = new Map(); + + 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(() => { - 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({ block: 'nearest', behavior: 'smooth' }); + setShouldAutoScroll(false); } - }, [selectedIndex]); + }, [selectedIndex, shouldAutoScroll]); if (!visible) return null; + let globalIndex = -1; + return ( <>
= ({ left: `${position.x}px`, top: `${position.y}px`, width: '300px', - maxHeight: '400px', + maxHeight: '500px', backgroundColor: '#2d2d2d', borderRadius: '6px', boxShadow: '0 4px 12px rgba(0,0,0,0.4)', @@ -109,13 +169,15 @@ export const QuickCreateMenu: React.FC = ({ onClose(); } else if (e.key === 'ArrowDown') { 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') { e.preventDefault(); + setShouldAutoScroll(true); onIndexChange(Math.max(selectedIndex - 1, 0)); - } else if (e.key === 'Enter' && filteredTemplates.length > 0) { + } else if (e.key === 'Enter' && flattenedTemplates.length > 0) { e.preventDefault(); - const selectedTemplate = filteredTemplates[selectedIndex]; + const selectedTemplate = flattenedTemplates[selectedIndex]; if (selectedTemplate) { onNodeSelect(selectedTemplate); } @@ -153,10 +215,10 @@ export const QuickCreateMenu: React.FC = ({ style={{ flex: 1, overflowY: 'auto', - padding: '8px' + padding: '4px' }} > - {filteredTemplates.length === 0 ? ( + {categoryGroups.length === 0 ? (
= ({ 未找到匹配的节点
) : ( - filteredTemplates.map((template: NodeTemplate, index: number) => { - const IconComponent = template.icon ? iconMap[template.icon] : null; - const className = template.className || ''; - const isSelected = index === selectedIndex; + categoryGroups.map((group) => { return ( -
onNodeSelect(template)} - onMouseEnter={() => onIndexChange(index)} - style={{ - padding: '8px 12px', - marginBottom: '4px', - backgroundColor: isSelected ? '#0e639c' : '#1e1e1e', - borderLeft: `3px solid ${template.color || '#666'}`, - borderRadius: '3px', - cursor: 'pointer', - transition: 'all 0.15s', - transform: isSelected ? 'translateX(2px)' : 'translateX(0)' - }} - > -
- {IconComponent && ( - +
+
toggleCategory(group.category)} + style={{ + display: 'flex', + alignItems: 'center', + gap: '6px', + padding: '8px 12px', + backgroundColor: '#1e1e1e', + borderRadius: '3px', + cursor: 'pointer', + userSelect: 'none' + }} + > + {group.isExpanded ? ( + + ) : ( + )} -
-
- {template.displayName} -
- {className && ( -
- {className} -
- )} + + {group.category} + + + {group.templates.length} + +
+ + {group.isExpanded && ( +
+ {group.templates.map((template: NodeTemplate) => { + globalIndex++; + const IconComponent = template.icon ? iconMap[template.icon] : null; + const className = template.className || ''; + const isSelected = globalIndex === selectedIndex; + return ( +
onNodeSelect(template)} + onMouseEnter={() => onIndexChange(globalIndex)} + style={{ + padding: '8px 12px', + marginBottom: '4px', + backgroundColor: isSelected ? '#0e639c' : '#1e1e1e', + borderLeft: `3px solid ${template.color || '#666'}`, + borderRadius: '3px', + cursor: 'pointer', + transition: 'all 0.15s', + transform: isSelected ? 'translateX(2px)' : 'translateX(0)' + }} + > +
+ {IconComponent && ( + + )} +
+
+ {template.displayName} +
+ {className && ( +
+ {className} +
+ )} +
+
+
+ {template.description} +
+
+ ); + })}
-
-
- {template.description} -
-
- {template.category} -
+ )}
); }) diff --git a/packages/editor-app/src/presentation/hooks/useCanvasMouseEvents.ts b/packages/editor-app/src/presentation/hooks/useCanvasMouseEvents.ts index 2d6f9f11..ae0e60c3 100644 --- a/packages/editor-app/src/presentation/hooks/useCanvasMouseEvents.ts +++ b/packages/editor-app/src/presentation/hooks/useCanvasMouseEvents.ts @@ -1,4 +1,4 @@ -import { RefObject } from 'react'; +import { RefObject, useEffect, useRef } from 'react'; import { BehaviorTreeNode, ROOT_NODE_ID } from '../../stores/behaviorTreeStore'; interface QuickCreateMenuState { @@ -31,6 +31,7 @@ interface UseCanvasMouseEventsParams { setQuickCreateMenu: (menu: QuickCreateMenuState) => void; clearConnecting: () => void; clearBoxSelect: () => void; + showToast?: (message: string, type: 'success' | 'error' | 'warning' | 'info', duration?: number) => void; } export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) { @@ -54,9 +55,87 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) { setSelectedConnection, setQuickCreateMenu, clearConnecting, - clearBoxSelect + clearBoxSelect, + showToast } = 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) => { if (connectingFrom && canvasRef.current && !quickCreateMenu.visible) { const rect = canvasRef.current.getBoundingClientRect(); @@ -67,15 +146,6 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) { 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) => { @@ -84,6 +154,18 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) { } 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({ visible: true, position: { @@ -100,47 +182,21 @@ export function useCanvasMouseEvents(params: UseCanvasMouseEventsParams) { } 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) => { + 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) { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; diff --git a/packages/editor-app/src/presentation/hooks/useExecutionController.ts b/packages/editor-app/src/presentation/hooks/useExecutionController.ts index fea8bfe1..d7e475ab 100644 --- a/packages/editor-app/src/presentation/hooks/useExecutionController.ts +++ b/packages/editor-app/src/presentation/hooks/useExecutionController.ts @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo } from 'react'; import { ExecutionController, ExecutionMode } from '../../application/services/ExecutionController'; 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 { BlackboardValue } from '../../domain/models/Blackboard'; @@ -17,6 +17,8 @@ interface UseExecutionControllerParams { onBlackboardUpdate: (variables: BlackboardVariables) => void; onInitialBlackboardSave: (variables: BlackboardVariables) => void; onExecutingChange: (isExecuting: boolean) => void; + onSaveNodesDataSnapshot: () => void; + onRestoreNodesData: () => void; } export function useExecutionController(params: UseExecutionControllerParams) { @@ -28,7 +30,9 @@ export function useExecutionController(params: UseExecutionControllerParams) { connections, onBlackboardUpdate, onInitialBlackboardSave, - onExecutingChange + onExecutingChange, + onSaveNodesDataSnapshot, + onRestoreNodesData } = params; const [executionMode, setExecutionMode] = useState('idle'); @@ -42,9 +46,13 @@ export function useExecutionController(params: UseExecutionControllerParams) { projectPath, onLogsUpdate: setExecutionLogs, 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(), []); @@ -70,11 +78,18 @@ export function useExecutionController(params: UseExecutionControllerParams) { }); }, [blackboardVariables, executionMode, controller]); + useEffect(() => { + if (executionMode === 'idle') return; + + controller.updateNodes(nodes); + }, [nodes, executionMode, controller]); + const handlePlay = async () => { try { blackboardManager.setInitialVariables(blackboardVariables); blackboardManager.setCurrentVariables(blackboardVariables); onInitialBlackboardSave(blackboardManager.getInitialVariables()); + onSaveNodesDataSnapshot(); onExecutingChange(true); setExecutionMode('running'); @@ -104,6 +119,8 @@ export function useExecutionController(params: UseExecutionControllerParams) { const restoredVars = blackboardManager.restoreInitialVariables(); onBlackboardUpdate(restoredVars); + onRestoreNodesData(); + useBehaviorTreeStore.getState().clearNodeExecutionStatuses(); onExecutingChange(false); } catch (error) { console.error('Failed to stop execution:', error); diff --git a/packages/editor-app/src/stores/behaviorTreeStore.ts b/packages/editor-app/src/stores/behaviorTreeStore.ts index 763ab78b..2933c424 100644 --- a/packages/editor-app/src/stores/behaviorTreeStore.ts +++ b/packages/editor-app/src/stores/behaviorTreeStore.ts @@ -9,18 +9,29 @@ import { createRootNode, ROOT_NODE_ID } from '../domain/constants/RootNode'; /** * 行为树 Store 状态接口 */ +export type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure'; + +export interface NodeExecutionInfo { + status: NodeExecutionStatus; + executionOrder?: number; +} + interface BehaviorTreeState { + isOpen: boolean; nodes: Node[]; connections: Connection[]; blackboard: Blackboard; blackboardVariables: Record; initialBlackboardVariables: Record; + initialNodesData: Map>; selectedNodeIds: string[]; draggingNodeId: string | null; dragStartPositions: Map; isDraggingNode: boolean; isExecuting: boolean; + nodeExecutionStatuses: Map; + nodeExecutionOrders: Map; canvasOffset: { x: number; y: number }; canvasScale: number; @@ -84,6 +95,11 @@ interface BehaviorTreeState { setBlackboardVariables: (variables: Record) => void; setInitialBlackboardVariables: (variables: Record) => void; setIsExecuting: (isExecuting: boolean) => void; + saveNodesDataSnapshot: () => void; + restoreNodesData: () => void; + setNodeExecutionStatus: (nodeId: string, status: NodeExecutionStatus) => void; + updateNodeExecutionStatuses: (statuses: Map, orders?: Map) => void; + clearNodeExecutionStatuses: () => void; sortChildrenByPosition: () => void; @@ -95,6 +111,7 @@ interface BehaviorTreeState { format: 'json' | 'binary' ) => string | Uint8Array; + setIsOpen: (isOpen: boolean) => void; reset: () => void; } @@ -103,17 +120,21 @@ interface BehaviorTreeState { * 行为树 Store */ export const useBehaviorTreeStore = create((set, get) => ({ - nodes: [createRootNode()], + isOpen: false, + nodes: [], connections: [], blackboard: new Blackboard(), blackboardVariables: {}, initialBlackboardVariables: {}, + initialNodesData: new Map(), selectedNodeIds: [], draggingNodeId: null, dragStartPositions: new Map(), isDraggingNode: false, isExecuting: false, + nodeExecutionStatuses: new Map(), + nodeExecutionOrders: new Map(), canvasOffset: { x: 0, y: 0 }, canvasScale: 1, @@ -272,6 +293,45 @@ export const useBehaviorTreeStore = create((set, get) => ({ setIsExecuting: (isExecuting: boolean) => set({ isExecuting }), + saveNodesDataSnapshot: () => { + const snapshot = new Map>(); + 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, orders?: Map) => { + set({ + nodeExecutionStatuses: new Map(statuses), + nodeExecutionOrders: orders ? new Map(orders) : new Map() + }); + }, + + clearNodeExecutionStatuses: () => { + set({ nodeExecutionStatuses: new Map(), nodeExecutionOrders: new Map() }); + }, + sortChildrenByPosition: () => set((state: BehaviorTreeState) => { const nodeMap = new Map(); state.nodes.forEach((node) => nodeMap.set(node.id, node)); @@ -353,6 +413,7 @@ export const useBehaviorTreeStore = create((set, get) => ({ const loadedBlackboard = Blackboard.fromObject(blackboardData); set({ + isOpen: true, nodes: loadedNodes, connections: loadedConnections, blackboard: loadedBlackboard, @@ -391,8 +452,11 @@ export const useBehaviorTreeStore = create((set, get) => ({ }); }, + setIsOpen: (isOpen: boolean) => set({ isOpen }), + reset: () => set({ - nodes: [createRootNode()], + isOpen: false, + nodes: [], connections: [], blackboard: new Blackboard(), blackboardVariables: {}, diff --git a/packages/editor-app/src/styles/AssetBrowser.css b/packages/editor-app/src/styles/AssetBrowser.css index 3e88a1ba..a2d12a7e 100644 --- a/packages/editor-app/src/styles/AssetBrowser.css +++ b/packages/editor-app/src/styles/AssetBrowser.css @@ -253,6 +253,14 @@ color: #dcb67a; } +.asset-info { + flex: 1; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +} + .asset-name { font-size: var(--font-size-sm); color: #cccccc; diff --git a/packages/editor-app/src/styles/BehaviorTreeNode.css b/packages/editor-app/src/styles/BehaviorTreeNode.css index 5746414c..5afd71ed 100644 --- a/packages/editor-app/src/styles/BehaviorTreeNode.css +++ b/packages/editor-app/src/styles/BehaviorTreeNode.css @@ -18,16 +18,30 @@ 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 { 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 { 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 { - 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 { @@ -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 { display: none; position: absolute; diff --git a/packages/editor-app/src/styles/EntityInspector.css b/packages/editor-app/src/styles/EntityInspector.css index 734b2fb8..bfe3f637 100644 --- a/packages/editor-app/src/styles/EntityInspector.css +++ b/packages/editor-app/src/styles/EntityInspector.css @@ -61,7 +61,10 @@ } .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 { @@ -297,3 +300,151 @@ font-size: var(--font-size-sm); 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; +} diff --git a/packages/editor-app/src/styles/FileTree.css b/packages/editor-app/src/styles/FileTree.css index 0dd837fa..206f60cd 100644 --- a/packages/editor-app/src/styles/FileTree.css +++ b/packages/editor-app/src/styles/FileTree.css @@ -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 { - height: 100%; + height: calc(100% - 32px); overflow-y: auto; overflow-x: hidden; background: #1e1e1e; @@ -76,3 +106,15 @@ .tree-children { /* 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; +} diff --git a/packages/editor-app/src/styles/PromptDialog.css b/packages/editor-app/src/styles/PromptDialog.css new file mode 100644 index 00000000..930081b5 --- /dev/null +++ b/packages/editor-app/src/styles/PromptDialog.css @@ -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); +} diff --git a/packages/editor-app/src/utils/BehaviorTreeExecutor.ts b/packages/editor-app/src/utils/BehaviorTreeExecutor.ts index fe4f159d..c9a4f0ff 100644 --- a/packages/editor-app/src/utils/BehaviorTreeExecutor.ts +++ b/packages/editor-app/src/utils/BehaviorTreeExecutor.ts @@ -16,6 +16,7 @@ export interface ExecutionStatus { nodeId: string; status: 'running' | 'success' | 'failure' | 'idle'; message?: string; + executionOrder?: number; } export interface ExecutionLog { @@ -47,9 +48,12 @@ export class BehaviorTreeExecutor { private isPaused = false; private executionLogs: ExecutionLog[] = []; private lastStatuses: Map = new Map(); + private persistentStatuses: Map = new Map(); + private executionOrders: Map = new Map(); private tickCount = 0; private nodeIdMap: Map = new Map(); private blackboardKeys: string[] = []; + private rootNodeId: string = ''; private assetManager: BehaviorTreeAssetManager; private executionSystem: BehaviorTreeExecutionSystem; @@ -84,6 +88,7 @@ export class BehaviorTreeExecutor { this.callback = callback; this.treeData = this.convertToTreeData(nodes, rootNodeId, blackboard, connections); + this.rootNodeId = this.treeData.rootNodeId; this.assetManager.loadAsset(this.treeData); @@ -249,6 +254,7 @@ export class BehaviorTreeExecutor { this.isPaused = false; this.executionLogs = []; this.lastStatuses.clear(); + this.persistentStatuses.clear(); this.tickCount = 0; this.runtime.resetAllStates(); @@ -313,38 +319,102 @@ export class BehaviorTreeExecutor { private collectExecutionStatus(): void { 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[] = []; for (const [nodeId, nodeData] of this.treeData.nodes.entries()) { const state = this.runtime.getNodeState(nodeId); - let status: 'running' | 'success' | 'failure' | 'idle' = 'idle'; + let currentStatus: 'running' | 'success' | 'failure' | 'idle' = 'idle'; if (state) { switch (state.status) { case TaskStatus.Success: - status = 'success'; + currentStatus = 'success'; break; case TaskStatus.Failure: - status = 'failure'; + currentStatus = 'failure'; break; case TaskStatus.Running: - status = 'running'; + currentStatus = 'running'; break; default: - status = 'idle'; + currentStatus = 'idle'; } } + const persistentStatus = this.persistentStatuses.get(nodeId) || 'idle'; const lastStatus = this.lastStatuses.get(nodeId); - if (lastStatus !== status) { - this.onNodeStatusChanged(nodeId, nodeData.name, lastStatus || 'idle', status); - this.lastStatuses.set(nodeId, status); + + let displayStatus: 'running' | 'success' | 'failure' | 'idle' = currentStatus; + + 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({ nodeId, - status + status: displayStatus, + executionOrder: this.executionOrders.get(nodeId) }); } @@ -428,6 +498,7 @@ export class BehaviorTreeExecutor { this.stop(); this.nodeIdMap.clear(); this.lastStatuses.clear(); + this.persistentStatuses.clear(); this.blackboardKeys = []; if (this.entity) { diff --git a/packages/editor-core/src/Plugins/EditorPluginManager.ts b/packages/editor-core/src/Plugins/EditorPluginManager.ts index e69ba512..2a60c248 100644 --- a/packages/editor-core/src/Plugins/EditorPluginManager.ts +++ b/packages/editor-core/src/Plugins/EditorPluginManager.ts @@ -7,6 +7,7 @@ import { EditorPluginCategory } from './IEditorPlugin'; import { UIRegistry } from '../Services/UIRegistry'; import { MessageHub } from '../Services/MessageHub'; import { SerializerRegistry } from '../Services/SerializerRegistry'; +import { FileActionRegistry } from '../Services/FileActionRegistry'; const logger = createLogger('EditorPluginManager'); @@ -22,6 +23,7 @@ export class EditorPluginManager extends PluginManager { private uiRegistry: UIRegistry | null = null; private messageHub: MessageHub | 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.messageHub = services.resolve(MessageHub); this.serializerRegistry = services.resolve(SerializerRegistry); + this.fileActionRegistry = services.resolve(FileActionRegistry); logger.info('EditorPluginManager initialized'); } @@ -90,6 +93,24 @@ export class EditorPluginManager extends PluginManager { 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) { await plugin.onEditorReady(); } @@ -332,6 +353,7 @@ export class EditorPluginManager extends PluginManager { this.uiRegistry = null; this.messageHub = null; this.serializerRegistry = null; + this.fileActionRegistry = null; logger.info('EditorPluginManager disposed'); } diff --git a/packages/editor-core/src/Plugins/IEditorPlugin.ts b/packages/editor-core/src/Plugins/IEditorPlugin.ts index c3e00e73..3a4a7fe6 100644 --- a/packages/editor-core/src/Plugins/IEditorPlugin.ts +++ b/packages/editor-core/src/Plugins/IEditorPlugin.ts @@ -1,5 +1,6 @@ import type { IPlugin } from '@esengine/ecs-framework'; import type { MenuItem, ToolbarItem, PanelDescriptor } from '../Types/UITypes'; +import type { ReactNode } from 'react'; /** * 编辑器插件类别 @@ -51,6 +52,91 @@ export interface ISerializer { getSupportedType(): string; } +/** + * 文件上下文菜单项 + */ +export interface FileContextMenuItem { + /** + * 菜单项标签 + */ + label: string; + + /** + * 图标 + */ + icon?: ReactNode; + + /** + * 点击处理函数 + */ + onClick: (filePath: string, parentPath: string) => void | Promise; + + /** + * 是否禁用 + */ + disabled?: boolean; + + /** + * 是否为分隔符 + */ + separator?: boolean; +} + +/** + * 文件创建模板 + */ +export interface FileCreationTemplate { + /** + * 模板名称 + */ + label: string; + + /** + * 文件扩展名(不含点) + */ + extension: string; + + /** + * 默认文件名 + */ + defaultFileName: string; + + /** + * 图标 + */ + icon?: ReactNode; + + /** + * 创建文件内容的函数 + */ + createContent: (fileName: string) => string | Promise; +} + +/** + * 文件操作处理器 + */ +export interface FileActionHandler { + /** + * 支持的文件扩展名列表 + */ + extensions: string[]; + + /** + * 双击处理函数 + */ + onDoubleClick?: (filePath: string) => void | Promise; + + /** + * 打开文件处理函数 + */ + onOpen?: (filePath: string) => void | Promise; + + /** + * 获取上下文菜单项 + */ + getContextMenuItems?: (filePath: string, parentPath: string) => FileContextMenuItem[]; +} + /** * 编辑器插件接口 * @@ -131,6 +217,16 @@ export interface IEditorPlugin extends IPlugin { * 获取行为树节点模板 */ getNodeTemplates?(): any[]; + + /** + * 注册文件操作处理器 + */ + registerFileActionHandlers?(): FileActionHandler[]; + + /** + * 注册文件创建模板 + */ + registerFileCreationTemplates?(): FileCreationTemplate[]; } /** diff --git a/packages/editor-core/src/Services/FileActionRegistry.ts b/packages/editor-core/src/Services/FileActionRegistry.ts new file mode 100644 index 00000000..ec59515a --- /dev/null +++ b/packages/editor-core/src/Services/FileActionRegistry.ts @@ -0,0 +1,110 @@ +import { IService } from '@esengine/ecs-framework'; +import { FileActionHandler, FileCreationTemplate } from '../Plugins/IEditorPlugin'; + +/** + * 文件操作注册表服务 + * + * 管理插件注册的文件操作处理器和文件创建模板 + */ +export class FileActionRegistry implements IService { + private actionHandlers: Map = 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 { + 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 { + 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(); + } +} diff --git a/packages/editor-core/src/Services/WindowRegistry.ts b/packages/editor-core/src/Services/WindowRegistry.ts new file mode 100644 index 00000000..bc569d41 --- /dev/null +++ b/packages/editor-core/src/Services/WindowRegistry.ts @@ -0,0 +1,180 @@ +import { IService } from '@esengine/ecs-framework'; +import { ComponentType } from 'react'; + +/** + * 窗口描述符 + */ +export interface WindowDescriptor { + /** + * 窗口唯一标识 + */ + id: string; + + /** + * 窗口组件 + */ + component: ComponentType; + + /** + * 窗口标题 + */ + title?: string; + + /** + * 默认宽度 + */ + defaultWidth?: number; + + /** + * 默认高度 + */ + defaultHeight?: number; +} + +/** + * 窗口实例 + */ +export interface WindowInstance { + /** + * 窗口描述符 + */ + descriptor: WindowDescriptor; + + /** + * 是否打开 + */ + isOpen: boolean; + + /** + * 窗口参数 + */ + params?: Record; +} + +/** + * 窗口注册表服务 + * + * 管理插件注册的窗口组件 + */ +export class WindowRegistry implements IService { + private windows: Map = new Map(); + private openWindows: Map = 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): 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(); + } +} diff --git a/packages/editor-core/src/Types/UITypes.ts b/packages/editor-core/src/Types/UITypes.ts index 3ece76f8..18dc3f8c 100644 --- a/packages/editor-core/src/Types/UITypes.ts +++ b/packages/editor-core/src/Types/UITypes.ts @@ -146,6 +146,11 @@ export interface PanelDescriptor { * 排序权重 */ order?: number; + + /** + * 是否为动态面板(不默认显示,需要手动打开) + */ + isDynamic?: boolean; } /** diff --git a/packages/editor-core/src/index.ts b/packages/editor-core/src/index.ts index 2b538b11..a7af1264 100644 --- a/packages/editor-core/src/index.ts +++ b/packages/editor-core/src/index.ts @@ -19,6 +19,7 @@ export * from './Services/ComponentDiscoveryService'; export * from './Services/LogService'; export * from './Services/SettingsRegistry'; export * from './Services/SceneManagerService'; +export * from './Services/FileActionRegistry'; export * from './Types/UITypes'; export * from './Types/IFileAPI';