refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 (#216)

* refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构

* feat(editor): 添加插件市场功能

* feat(editor): 重构插件市场以支持版本管理和ZIP打包

* feat(editor): 重构插件发布流程并修复React渲染警告

* fix(plugin): 修复插件发布和市场的路径不一致问题

* feat: 重构插件发布流程并添加插件删除功能

* fix(editor): 完善插件删除功能并修复多个关键问题

* fix(auth): 修复自动登录与手动登录的竞态条件问题

* feat(editor): 重构插件管理流程

* feat(editor): 支持 ZIP 文件直接发布插件

- 新增 PluginSourceParser 解析插件源
- 重构发布流程支持文件夹和 ZIP 两种方式
- 优化发布向导 UI

* feat(editor): 插件市场支持多版本安装

- 插件解压到项目 plugins 目录
- 新增 Tauri 后端安装/卸载命令
- 支持选择任意版本安装
- 修复打包逻辑,保留完整 dist 目录结构

* feat(editor): 个人中心支持多版本管理

- 合并同一插件的不同版本
- 添加版本历史展开/折叠功能
- 禁止有待审核 PR 时更新插件

* fix(editor): 修复 InspectorRegistry 服务注册

- InspectorRegistry 实现 IService 接口
- 注册到 Core.services 供插件使用

* feat(behavior-tree-editor): 完善插件注册和文件操作

- 添加文件创建模板和操作处理器
- 实现右键菜单创建行为树功能
- 修复文件读取权限问题(使用 Tauri 命令)
- 添加 BehaviorTreeEditorPanel 组件
- 修复 rollup 配置支持动态导入

* feat(plugin): 完善插件构建和发布流程

* fix(behavior-tree-editor): 完整恢复编辑器并修复 Toast 集成

* fix(behavior-tree-editor): 修复节点选中、连线跟随和文件加载问题并优化性能

* fix(behavior-tree-editor): 修复端口连接失败问题并优化连线样式

* refactor(behavior-tree-editor): 移除调试面板功能简化代码结构

* refactor(behavior-tree-editor): 清理冗余代码合并重复逻辑

* feat(behavior-tree-editor): 完善编辑器核心功能增强扩展性

* fix(lint): 修复ESLint错误确保CI通过

* refactor(behavior-tree-editor): 优化编辑器工具栏和编译器功能

* refactor(behavior-tree-editor): 清理技术债务,优化代码质量

* fix(editor-app): 修复字符串替换安全问题
This commit is contained in:
YHH
2025-11-18 14:46:51 +08:00
committed by GitHub
parent eac660b1a0
commit bce3a6e253
251 changed files with 26144 additions and 8844 deletions

View File

@@ -1,12 +1,24 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Core, Scene, createLogger } from '@esengine/ecs-framework';
import { Core, createLogger, 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, FileActionRegistry, PanelDescriptor } from '@esengine/editor-core';
import {
EditorPluginManager,
UIRegistry,
MessageHub,
EntityStoreService,
ComponentRegistry,
LocaleService,
LogService,
SettingsRegistry,
SceneManagerService,
ProjectService,
CompilerRegistry,
InspectorRegistry,
INotification
} from '@esengine/editor-core';
import type { IDialogExtended } from './services/TauriDialogService';
import { GlobalBlackboardService } from '@esengine/behavior-tree';
import { SceneInspectorPlugin } from './plugins/SceneInspectorPlugin';
import { ProfilerPlugin } from './plugins/ProfilerPlugin';
import { EditorAppearancePlugin } from './plugins/EditorAppearancePlugin';
import { BehaviorTreePlugin } from './plugins/BehaviorTreePlugin';
import { ServiceRegistry, PluginInstaller, useDialogStore } from './app/managers';
import { StartupPage } from './components/StartupPage';
import { SceneHierarchy } from './components/SceneHierarchy';
import { Inspector } from './components/Inspector';
@@ -20,13 +32,18 @@ import { AboutDialog } from './components/AboutDialog';
import { ErrorDialog } from './components/ErrorDialog';
import { ConfirmDialog } from './components/ConfirmDialog';
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
import { ToastProvider } from './components/Toast';
import { ToastProvider, useToast } from './components/Toast';
import { MenuBar } from './components/MenuBar';
import { UserProfile } from './components/UserProfile';
import { UserDashboard } from './components/UserDashboard';
import { FlexLayoutDockContainer, FlexDockPanel } from './components/FlexLayoutDockContainer';
import { TauriAPI } from './api/tauri';
import { TauriFileAPI } from './adapters/TauriFileAPI';
import { SettingsService } from './services/SettingsService';
import { PluginLoader } from './services/PluginLoader';
import { GitHubService } from './services/GitHubService';
import { PluginPublishWizard } from './components/PluginPublishWizard';
import { GitHubLoginDialog } from './components/GitHubLoginDialog';
import { CompilerConfigDialog } from './components/CompilerConfigDialog';
import { checkForUpdatesOnStartup } from './utils/updater';
import { useLocale } from './hooks/useLocale';
import { en, zh } from './locales';
@@ -41,12 +58,15 @@ localeService.registerTranslations('zh', zh);
Core.services.registerInstance(LocaleService, localeService);
Core.services.registerSingleton(GlobalBlackboardService);
Core.services.registerSingleton(CompilerRegistry);
const logger = createLogger('App');
function App() {
const initRef = useRef(false);
const pluginLoaderRef = useRef<PluginLoader>(new PluginLoader());
const [pluginLoader] = useState(() => new PluginLoader());
const [githubService] = useState(() => new GitHubService());
const { showToast, hideToast } = useToast();
const [initialized, setInitialized] = useState(false);
const [projectLoaded, setProjectLoaded] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -55,34 +75,48 @@ function App() {
const [pluginManager, setPluginManager] = useState<EditorPluginManager | null>(null);
const [entityStore, setEntityStore] = useState<EntityStoreService | null>(null);
const [messageHub, setMessageHub] = useState<MessageHub | null>(null);
const [inspectorRegistry, setInspectorRegistry] = useState<InspectorRegistry | null>(null);
const [logService, setLogService] = useState<LogService | null>(null);
const [uiRegistry, setUiRegistry] = useState<UIRegistry | null>(null);
const [settingsRegistry, setSettingsRegistry] = useState<SettingsRegistry | null>(null);
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
const [notification, setNotification] = useState<INotification | null>(null);
const [dialog, setDialog] = useState<IDialogExtended | null>(null);
const { t, locale, changeLocale } = useLocale();
// 同步 locale 到 TauriDialogService
useEffect(() => {
if (dialog) {
dialog.setLocale(locale);
}
}, [locale, dialog]);
const [status, setStatus] = useState(t('header.status.initializing'));
const [panels, setPanels] = useState<FlexDockPanel[]>([]);
const [showPluginManager, setShowPluginManager] = useState(false);
const [showProfiler, setShowProfiler] = useState(false);
const [showPortManager, setShowPortManager] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showAbout, setShowAbout] = useState(false);
const [showPluginGenerator, setShowPluginGenerator] = useState(false);
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
const [isProfilerMode, setIsProfilerMode] = useState(false);
const [errorDialog, setErrorDialog] = useState<{ title: string; message: string } | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
title: string;
message: string;
confirmText: string;
cancelText: string;
onConfirm: () => void;
} | null>(null);
const {
showPluginManager, setShowPluginManager,
showProfiler, setShowProfiler,
showPortManager, setShowPortManager,
showSettings, setShowSettings,
showAbout, setShowAbout,
showPluginGenerator, setShowPluginGenerator,
errorDialog, setErrorDialog,
confirmDialog, setConfirmDialog
} = useDialogStore();
const [activeDynamicPanels, setActiveDynamicPanels] = useState<string[]>([]);
const [activePanelId, setActivePanelId] = useState<string | undefined>(undefined);
const [dynamicPanelTitles, setDynamicPanelTitles] = useState<Map<string, string>>(new Map());
const [isEditorFullscreen, setIsEditorFullscreen] = useState(false);
const [showLoginDialog, setShowLoginDialog] = useState(false);
const [showDashboard, setShowDashboard] = useState(false);
const [compilerDialog, setCompilerDialog] = useState<{
isOpen: boolean;
compilerId: string;
currentFileName?: string;
}>({ isOpen: false, compilerId: '' });
useEffect(() => {
// 禁用默认右键菜单
@@ -90,12 +124,22 @@ function App() {
e.preventDefault();
};
// 添加快捷键监听Ctrl+R 重新加载插件)
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
e.preventDefault();
handleReloadPlugins();
}
};
document.addEventListener('contextmenu', handleContextMenu);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('contextmenu', handleContextMenu);
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
}, [currentProjectPath, pluginManager, locale]);
useEffect(() => {
if (messageHub) {
@@ -107,41 +151,50 @@ function App() {
setPluginUpdateTrigger((prev) => prev + 1);
});
const unsubscribeNotification = messageHub.subscribe('notification:show', (notification: { message: string; type: 'success' | 'error' | 'warning' | 'info'; timestamp: number }) => {
if (notification && notification.message) {
showToast(notification.message, notification.type);
}
});
return () => {
unsubscribeEnabled();
unsubscribeDisabled();
unsubscribeNotification();
};
}
}, [messageHub]);
}, [messageHub, showToast]);
// 监听远程连接状态
useEffect(() => {
const checkConnection = () => {
const profilerService = (window as any).__PROFILER_SERVICE__;
if (profilerService && profilerService.isConnected()) {
if (!isRemoteConnected) {
setIsRemoteConnected(true);
setStatus(t('header.status.remoteConnected'));
}
} else {
if (isRemoteConnected) {
setIsRemoteConnected(false);
if (projectLoaded) {
const componentRegistry = Core.services.resolve(ComponentRegistry);
const componentCount = componentRegistry?.getAllComponents().length || 0;
setStatus(t('header.status.projectOpened') + (componentCount > 0 ? ` (${componentCount} components registered)` : ''));
const connected = profilerService && profilerService.isConnected();
setIsRemoteConnected((prevConnected) => {
if (connected !== prevConnected) {
// 状态发生变化
if (connected) {
setStatus(t('header.status.remoteConnected'));
} else {
setStatus(t('header.status.ready'));
if (projectLoaded) {
const componentRegistry = Core.services.resolve(ComponentRegistry);
const componentCount = componentRegistry?.getAllComponents().length || 0;
setStatus(t('header.status.projectOpened') + (componentCount > 0 ? ` (${componentCount} components registered)` : ''));
} else {
setStatus(t('header.status.ready'));
}
}
return connected;
}
}
return prevConnected;
});
};
checkConnection();
const interval = setInterval(checkConnection, 1000);
return () => clearInterval(interval);
}, [projectLoaded, isRemoteConnected, t]);
}, [projectLoaded, t]);
useEffect(() => {
const initializeEditor = async () => {
@@ -157,53 +210,21 @@ function App() {
const editorScene = new Scene();
Core.setScene(editorScene);
const uiRegistry = new UIRegistry();
const messageHub = new MessageHub();
const serializerRegistry = new SerializerRegistry();
const entityStore = new EntityStoreService(messageHub);
const componentRegistry = new ComponentRegistry();
const fileAPI = new TauriFileAPI();
const projectService = new ProjectService(messageHub, fileAPI);
const componentDiscovery = new ComponentDiscoveryService(messageHub);
const propertyMetadata = new PropertyMetadataService();
const logService = new LogService();
const settingsRegistry = new SettingsRegistry();
const sceneManagerService = new SceneManagerService(messageHub, fileAPI, projectService);
const fileActionRegistry = new FileActionRegistry();
const serviceRegistry = new ServiceRegistry();
const services = serviceRegistry.registerAllServices(coreInstance);
// 监听远程日志事件
window.addEventListener('profiler:remote-log', ((event: CustomEvent) => {
const { level, message, timestamp, clientId } = event.detail;
logService.addRemoteLog(level, message, timestamp, clientId);
}) as EventListener);
serviceRegistry.setupRemoteLogListener(services.logService);
Core.services.registerInstance(UIRegistry, uiRegistry);
Core.services.registerInstance(MessageHub, messageHub);
Core.services.registerInstance(SerializerRegistry, serializerRegistry);
Core.services.registerInstance(EntityStoreService, entityStore);
Core.services.registerInstance(ComponentRegistry, componentRegistry);
Core.services.registerInstance(ProjectService, projectService);
Core.services.registerInstance(ComponentDiscoveryService, componentDiscovery);
Core.services.registerInstance(PropertyMetadataService, propertyMetadata);
Core.services.registerInstance(LogService, logService);
Core.services.registerInstance(SettingsRegistry, settingsRegistry);
Core.services.registerInstance(SceneManagerService, sceneManagerService);
Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
const pluginInstaller = new PluginInstaller();
await pluginInstaller.installBuiltinPlugins(services.pluginManager);
const pluginMgr = new EditorPluginManager();
pluginMgr.initialize(coreInstance, Core.services);
Core.services.registerInstance(EditorPluginManager, pluginMgr);
services.notification.setCallbacks(showToast, hideToast);
(services.dialog as IDialogExtended).setConfirmCallback(setConfirmDialog);
await pluginMgr.installEditor(new SceneInspectorPlugin());
await pluginMgr.installEditor(new ProfilerPlugin());
await pluginMgr.installEditor(new EditorAppearancePlugin());
await pluginMgr.installEditor(new BehaviorTreePlugin());
messageHub.subscribe('ui:openWindow', (data: any) => {
services.messageHub.subscribe('ui:openWindow', (data: any) => {
console.log('[App] Received ui:openWindow:', data);
const { windowId, ...params } = data;
const { windowId } = data;
// 内置窗口处理
if (windowId === 'profiler') {
setShowProfiler(true);
} else if (windowId === 'pluginManager') {
@@ -211,16 +232,17 @@ function App() {
}
});
await TauriAPI.greet('Developer');
setInitialized(true);
setPluginManager(pluginMgr);
setEntityStore(entityStore);
setMessageHub(messageHub);
setLogService(logService);
setUiRegistry(uiRegistry);
setSettingsRegistry(settingsRegistry);
setSceneManager(sceneManagerService);
setPluginManager(services.pluginManager);
setEntityStore(services.entityStore);
setMessageHub(services.messageHub);
setInspectorRegistry(services.inspectorRegistry);
setLogService(services.logService);
setUiRegistry(services.uiRegistry);
setSettingsRegistry(services.settingsRegistry);
setSceneManager(services.sceneManager);
setNotification(services.notification);
setDialog(services.dialog as IDialogExtended);
setStatus(t('header.status.ready'));
// Check for updates on startup (after 3 seconds)
@@ -271,6 +293,25 @@ function App() {
return () => unsubscribe?.();
}, [messageHub]);
useEffect(() => {
if (!messageHub) return;
const unsubscribe = messageHub.subscribe('compiler:open-dialog', (data: {
compilerId: string;
currentFileName?: string;
projectPath?: string;
}) => {
logger.info('Opening compiler dialog:', data.compilerId);
setCompilerDialog({
isOpen: true,
compilerId: data.compilerId,
currentFileName: data.currentFileName
});
});
return () => unsubscribe?.();
}, [messageHub]);
const handleOpenRecentProject = async (projectPath: string) => {
try {
setIsLoading(true);
@@ -323,7 +364,7 @@ function App() {
if (pluginManager) {
setLoadingMessage(locale === 'zh' ? '加载项目插件...' : 'Loading project plugins...');
await pluginLoaderRef.current.loadProjectPlugins(projectPath, pluginManager);
await pluginLoader.loadProjectPlugins(projectPath, pluginManager);
}
setIsLoading(false);
@@ -520,7 +561,7 @@ function App() {
const handleCloseProject = async () => {
if (pluginManager) {
await pluginLoaderRef.current.unloadProjectPlugins(pluginManager);
await pluginLoader.unloadProjectPlugins(pluginManager);
}
setProjectLoaded(false);
setCurrentProjectPath(null);
@@ -568,6 +609,48 @@ function App() {
setShowPluginGenerator(true);
};
const handleReloadPlugins = async () => {
if (currentProjectPath && pluginManager) {
try {
console.log('[App] Starting plugin hot reload...');
// 1. 关闭所有动态面板
console.log('[App] Closing all dynamic panels');
setActiveDynamicPanels([]);
// 2. 清空当前面板列表(强制卸载插件面板组件)
console.log('[App] Clearing plugin panels');
setPanels((prev) => prev.filter((p) =>
['scene-hierarchy', 'inspector', 'console', 'asset-browser'].includes(p.id)
));
// 3. 等待React完成卸载
await new Promise(resolve => setTimeout(resolve, 200));
// 4. 卸载所有项目插件清理UIRegistry、调用uninstall
console.log('[App] Unloading all project plugins');
await pluginLoader.unloadProjectPlugins(pluginManager);
// 5. 等待卸载完成
await new Promise(resolve => setTimeout(resolve, 100));
// 6. 重新加载插件
console.log('[App] Reloading project plugins');
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManager);
// 7. 触发面板重新渲染
console.log('[App] Triggering panel re-render');
setPluginUpdateTrigger((prev) => prev + 1);
showToast(locale === 'zh' ? '插件已重新加载' : 'Plugins reloaded', 'success');
console.log('[App] Plugin hot reload completed');
} catch (error) {
console.error('Failed to reload plugins:', error);
showToast(locale === 'zh' ? '重新加载插件失败' : 'Failed to reload plugins', 'error');
}
}
};
useEffect(() => {
if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) {
let corePanels: FlexDockPanel[];
@@ -583,7 +666,7 @@ function App() {
{
id: 'inspector',
title: locale === 'zh' ? '检视器' : 'Inspector',
content: <Inspector entityStore={entityStore} messageHub={messageHub} projectPath={currentProjectPath} />,
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} />,
closable: false
},
{
@@ -604,7 +687,7 @@ function App() {
{
id: 'inspector',
title: locale === 'zh' ? '检视器' : 'Inspector',
content: <Inspector entityStore={entityStore} messageHub={messageHub} projectPath={currentProjectPath} />,
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} />,
closable: false
},
{
@@ -631,7 +714,6 @@ function App() {
if (!panelDesc.component) {
return false;
}
// 过滤掉动态面板
if (panelDesc.isDynamic) {
return false;
}
@@ -649,7 +731,7 @@ function App() {
return {
id: panelDesc.id,
title: (panelDesc as any).titleZh && locale === 'zh' ? (panelDesc as any).titleZh : panelDesc.title,
content: <Component projectPath={currentProjectPath} />,
content: <Component key={`${panelDesc.id}-${pluginUpdateTrigger}`} projectPath={currentProjectPath} />,
closable: panelDesc.closable ?? true
};
});
@@ -725,8 +807,16 @@ function App() {
message={confirmDialog.message}
confirmText={confirmDialog.confirmText}
cancelText={confirmDialog.cancelText}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
onConfirm={() => {
confirmDialog.onConfirm();
setConfirmDialog(null);
}}
onCancel={() => {
if (confirmDialog.onCancel) {
confirmDialog.onCancel();
}
setConfirmDialog(null);
}}
/>
)}
</>
@@ -756,8 +846,15 @@ function App() {
onToggleDevtools={handleToggleDevtools}
onOpenAbout={handleOpenAbout}
onCreatePlugin={handleCreatePlugin}
onReloadPlugins={handleReloadPlugins}
/>
<div className="header-right">
<UserProfile
githubService={githubService}
onLogin={() => setShowLoginDialog(true)}
onOpenDashboard={() => setShowDashboard(true)}
locale={locale}
/>
<button onClick={handleLocaleChange} className="toolbar-btn locale-btn" title={locale === 'en' ? '切换到中文' : 'Switch to English'}>
<Globe size={14} />
</button>
@@ -766,6 +863,37 @@ function App() {
</div>
)}
{showLoginDialog && (
<GitHubLoginDialog
githubService={githubService}
onClose={() => setShowLoginDialog(false)}
locale={locale}
/>
)}
{showDashboard && (
<UserDashboard
githubService={githubService}
onClose={() => setShowDashboard(false)}
locale={locale}
/>
)}
<CompilerConfigDialog
isOpen={compilerDialog.isOpen}
compilerId={compilerDialog.compilerId}
projectPath={currentProjectPath}
currentFileName={compilerDialog.currentFileName}
onClose={() => setCompilerDialog({ isOpen: false, compilerId: '' })}
onCompileComplete={(result) => {
if (result.success) {
showToast(result.message, 'success');
} else {
showToast(result.message, 'error');
}
}}
/>
<div className="editor-content">
<FlexLayoutDockContainer
panels={panels}
@@ -783,11 +911,13 @@ function App() {
<span>{t('footer.core')}: {t('footer.active')}</span>
</div>
{showPluginManager && pluginManager && (
{showPluginManager && pluginManager && notification && dialog && (
<PluginManagerWindow
pluginManager={pluginManager}
githubService={githubService}
onClose={() => setShowPluginManager(false)}
locale={locale}
projectPath={currentProjectPath}
onOpen={() => {
// 同步所有插件的语言状态
const allPlugins = pluginManager.getAllEditorPlugins();
@@ -799,7 +929,7 @@ function App() {
}}
onRefresh={async () => {
if (currentProjectPath && pluginManager) {
await pluginLoaderRef.current.loadProjectPlugins(currentProjectPath, pluginManager);
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManager);
}
}}
/>
@@ -828,7 +958,7 @@ function App() {
locale={locale}
onSuccess={async () => {
if (currentProjectPath && pluginManager) {
await pluginLoaderRef.current.loadProjectPlugins(currentProjectPath, pluginManager);
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManager);
}
}}
/>
@@ -841,6 +971,25 @@ function App() {
onClose={() => setErrorDialog(null)}
/>
)}
{confirmDialog && (
<ConfirmDialog
title={confirmDialog.title}
message={confirmDialog.message}
confirmText={confirmDialog.confirmText}
cancelText={confirmDialog.cancelText}
onConfirm={() => {
confirmDialog.onConfirm();
setConfirmDialog(null);
}}
onCancel={() => {
if (confirmDialog.onCancel) {
confirmDialog.onCancel();
}
setConfirmDialog(null);
}}
/>
)}
</div>
);
}

View File

@@ -1,18 +1,47 @@
import { invoke } from '@tauri-apps/api/core';
/**
* 文件过滤器定义
*/
interface FileFilter {
name: string;
extensions: string[];
}
/**
* Tauri IPC 通信层
*/
export class TauriAPI {
/**
* 打招呼(测试命令)
*/
static async greet(name: string): Promise<string> {
return await invoke<string>('greet', { name });
static async openFolderDialog(title?: string): Promise<string | null> {
return await invoke<string | null>('open_folder_dialog', { title });
}
static async openFileDialog(
title?: string,
filters?: FileFilter[],
multiple?: boolean
): Promise<string[] | null> {
return await invoke<string[] | null>('open_file_dialog', {
title,
filters,
multiple
});
}
static async saveFileDialog(
title?: string,
defaultName?: string,
filters?: FileFilter[]
): Promise<string | null> {
return await invoke<string | null>('save_file_dialog', {
title,
defaultName,
filters
});
}
static async openProjectDialog(): Promise<string | null> {
return await invoke<string | null>('open_project_dialog');
return await this.openFolderDialog('Select Project Directory');
}
static async openProject(path: string): Promise<string> {
@@ -77,7 +106,11 @@ export class TauriAPI {
* @returns 用户选择的文件路径,取消则返回 null
*/
static async saveSceneDialog(defaultName?: string): Promise<string | null> {
return await invoke<string | null>('save_scene_dialog', { defaultName });
return await this.saveFileDialog(
'Save ECS Scene',
defaultName,
[{ name: 'ECS Scene Files', extensions: ['ecs'] }]
);
}
/**
@@ -85,7 +118,12 @@ export class TauriAPI {
* @returns 用户选择的文件路径,取消则返回 null
*/
static async openSceneDialog(): Promise<string | null> {
return await invoke<string | null>('open_scene_dialog');
const result = await this.openFileDialog(
'Open ECS Scene',
[{ name: 'ECS Scene Files', extensions: ['ecs'] }],
false
);
return result && result[0] ? result[0] : null;
}
/**
@@ -135,13 +173,18 @@ export class TauriAPI {
* @returns 用户选择的文件路径,取消则返回 null
*/
static async openBehaviorTreeDialog(): Promise<string | null> {
return await invoke<string | null>('open_behavior_tree_dialog');
const result = await this.openFileDialog(
'Select Behavior Tree',
[{ name: 'Behavior Tree Files', extensions: ['btree'] }],
false
);
return result && result[0] ? result[0] : null;
}
/**
* 扫描项目中的所有行为树文件
* @param projectPath 项目路径
* @returns 行为树资产ID列表相对于 .ecs/behaviors 的路径,不含扩展名)
* @returns 行为树资产ID列表相对于 .ecs/behaviors 的路 径,不含扩展名)
*/
static async scanBehaviorTrees(projectPath: string): Promise<string[]> {
return await invoke<string[]>('scan_behavior_trees', { projectPath });
@@ -179,30 +222,39 @@ export class TauriAPI {
static async createFile(path: string): Promise<void> {
return await invoke<void>('create_file', { path });
}
/**
* 读取文件并转换为base64
* @param path 文件路径
* @returns base64编码的文件内容
*/
static async readFileAsBase64(path: string): Promise<string> {
return await invoke<string>('read_file_as_base64', { filePath: path });
}
}
export interface DirectoryEntry {
name: string;
path: string;
is_dir: boolean;
size?: number;
modified?: number;
name: string;
path: string;
is_dir: boolean;
size?: number;
modified?: number;
}
/**
* 项目信息
*/
export interface ProjectInfo {
name: string;
path: string;
version: string;
name: string;
path: string;
version: string;
}
/**
* 编辑器配置
*/
export interface EditorConfig {
theme: string;
autoSave: boolean;
recentProjects: string[];
theme: string;
autoSave: boolean;
recentProjects: string[];
}

View File

@@ -0,0 +1,59 @@
import { create } from 'zustand';
import type { ConfirmDialogData } from '../../services/TauriDialogService';
interface ErrorDialogData {
title: string;
message: string;
}
interface DialogState {
showPluginManager: boolean;
showProfiler: boolean;
showPortManager: boolean;
showSettings: boolean;
showAbout: boolean;
showPluginGenerator: boolean;
errorDialog: ErrorDialogData | null;
confirmDialog: ConfirmDialogData | null;
setShowPluginManager: (show: boolean) => void;
setShowProfiler: (show: boolean) => void;
setShowPortManager: (show: boolean) => void;
setShowSettings: (show: boolean) => void;
setShowAbout: (show: boolean) => void;
setShowPluginGenerator: (show: boolean) => void;
setErrorDialog: (data: ErrorDialogData | null) => void;
setConfirmDialog: (data: ConfirmDialogData | null) => void;
closeAllDialogs: () => void;
}
export const useDialogStore = create<DialogState>((set) => ({
showPluginManager: false,
showProfiler: false,
showPortManager: false,
showSettings: false,
showAbout: false,
showPluginGenerator: false,
errorDialog: null,
confirmDialog: null,
setShowPluginManager: (show) => set({ showPluginManager: show }),
setShowProfiler: (show) => set({ showProfiler: show }),
setShowPortManager: (show) => set({ showPortManager: show }),
setShowSettings: (show) => set({ showSettings: show }),
setShowAbout: (show) => set({ showAbout: show }),
setShowPluginGenerator: (show) => set({ showPluginGenerator: show }),
setErrorDialog: (data) => set({ errorDialog: data }),
setConfirmDialog: (data) => set({ confirmDialog: data }),
closeAllDialogs: () => set({
showPluginManager: false,
showProfiler: false,
showPortManager: false,
showSettings: false,
showAbout: false,
showPluginGenerator: false,
errorDialog: null,
confirmDialog: null
})
}));

View File

@@ -0,0 +1,27 @@
import type { EditorPluginManager } from '@esengine/editor-core';
import { SceneInspectorPlugin } from '../../plugins/SceneInspectorPlugin';
import { ProfilerPlugin } from '../../plugins/ProfilerPlugin';
import { EditorAppearancePlugin } from '../../plugins/EditorAppearancePlugin';
export class PluginInstaller {
async installBuiltinPlugins(pluginManager: EditorPluginManager): Promise<void> {
console.log('[PluginInstaller] Installing builtin plugins...');
const plugins = [
new SceneInspectorPlugin(),
new ProfilerPlugin(),
new EditorAppearancePlugin()
];
for (const plugin of plugins) {
try {
await pluginManager.installEditor(plugin);
console.log(`[PluginInstaller] Installed plugin: ${plugin.name}`);
} catch (error) {
console.error(`[PluginInstaller] Failed to install plugin ${plugin.name}:`, error);
}
}
console.log('[PluginInstaller] All builtin plugins installed');
}
}

View File

@@ -0,0 +1,129 @@
import { Core } from '@esengine/ecs-framework';
import {
UIRegistry,
MessageHub,
SerializerRegistry,
EntityStoreService,
ComponentRegistry,
ProjectService,
ComponentDiscoveryService,
PropertyMetadataService,
LogService,
SettingsRegistry,
SceneManagerService,
FileActionRegistry,
EditorPluginManager,
InspectorRegistry
} from '@esengine/editor-core';
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
import { DIContainer } from '../../core/di/DIContainer';
import { TypedEventBus } from '../../core/events/TypedEventBus';
import { CommandRegistry } from '../../core/commands/CommandRegistry';
import { PanelRegistry } from '../../core/commands/PanelRegistry';
import type { EditorEventMap } from '../../core/events/EditorEventMap';
import { TauriFileSystemService } from '../../services/TauriFileSystemService';
import { TauriDialogService } from '../../services/TauriDialogService';
import { NotificationService } from '../../services/NotificationService';
export interface EditorServices {
uiRegistry: UIRegistry;
messageHub: MessageHub;
serializerRegistry: SerializerRegistry;
entityStore: EntityStoreService;
componentRegistry: ComponentRegistry;
projectService: ProjectService;
componentDiscovery: ComponentDiscoveryService;
propertyMetadata: PropertyMetadataService;
logService: LogService;
settingsRegistry: SettingsRegistry;
sceneManager: SceneManagerService;
fileActionRegistry: FileActionRegistry;
pluginManager: EditorPluginManager;
diContainer: DIContainer;
eventBus: TypedEventBus<EditorEventMap>;
commandRegistry: CommandRegistry;
panelRegistry: PanelRegistry;
fileSystem: TauriFileSystemService;
dialog: TauriDialogService;
notification: NotificationService;
inspectorRegistry: InspectorRegistry;
}
export class ServiceRegistry {
registerAllServices(coreInstance: Core): EditorServices {
const fileAPI = new TauriFileAPI();
const uiRegistry = new UIRegistry();
const messageHub = new MessageHub();
const serializerRegistry = new SerializerRegistry();
const entityStore = new EntityStoreService(messageHub);
const componentRegistry = new ComponentRegistry();
const projectService = new ProjectService(messageHub, fileAPI);
const componentDiscovery = new ComponentDiscoveryService(messageHub);
const propertyMetadata = new PropertyMetadataService();
const logService = new LogService();
const settingsRegistry = new SettingsRegistry();
const sceneManager = new SceneManagerService(messageHub, fileAPI, projectService);
const fileActionRegistry = new FileActionRegistry();
Core.services.registerInstance(UIRegistry, uiRegistry);
Core.services.registerInstance(MessageHub, messageHub);
Core.services.registerInstance(SerializerRegistry, serializerRegistry);
Core.services.registerInstance(EntityStoreService, entityStore);
Core.services.registerInstance(ComponentRegistry, componentRegistry);
Core.services.registerInstance(ProjectService, projectService);
Core.services.registerInstance(ComponentDiscoveryService, componentDiscovery);
Core.services.registerInstance(PropertyMetadataService, propertyMetadata);
Core.services.registerInstance(LogService, logService);
Core.services.registerInstance(SettingsRegistry, settingsRegistry);
Core.services.registerInstance(SceneManagerService, sceneManager);
Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
const pluginManager = new EditorPluginManager();
pluginManager.initialize(coreInstance, Core.services);
Core.services.registerInstance(EditorPluginManager, pluginManager);
const diContainer = new DIContainer();
const eventBus = new TypedEventBus<EditorEventMap>();
const commandRegistry = new CommandRegistry();
const panelRegistry = new PanelRegistry();
const fileSystem = new TauriFileSystemService();
const dialog = new TauriDialogService();
const notification = new NotificationService();
const inspectorRegistry = new InspectorRegistry();
Core.services.registerInstance(InspectorRegistry, inspectorRegistry);
return {
uiRegistry,
messageHub,
serializerRegistry,
entityStore,
componentRegistry,
projectService,
componentDiscovery,
propertyMetadata,
logService,
settingsRegistry,
sceneManager,
fileActionRegistry,
pluginManager,
diContainer,
eventBus,
commandRegistry,
panelRegistry,
fileSystem,
dialog,
notification,
inspectorRegistry
};
}
setupRemoteLogListener(logService: LogService): void {
window.addEventListener('profiler:remote-log', ((event: CustomEvent) => {
const { level, message, timestamp, clientId } = event.detail;
logService.addRemoteLog(level, message, timestamp, clientId);
}) as EventListener);
}
}

View File

@@ -0,0 +1,3 @@
export * from './ServiceRegistry';
export * from './DialogManager';
export * from './PluginInstaller';

View File

@@ -1,17 +0,0 @@
import { BehaviorTree } from '../../domain/models/BehaviorTree';
/**
* 行为树状态接口
* 命令通过此接口操作状态
*/
export interface ITreeState {
/**
* 获取当前行为树
*/
getTree(): BehaviorTree;
/**
* 设置行为树
*/
setTree(tree: BehaviorTree): void;
}

View File

@@ -1,5 +1,3 @@
export type { ICommand } from './ICommand';
export { BaseCommand } from './BaseCommand';
export { CommandManager } from './CommandManager';
export type { ITreeState } from './ITreeState';
export * from './tree';

View File

@@ -1,36 +0,0 @@
import { Connection } from '../../../domain/models/Connection';
import { BaseCommand } from '../BaseCommand';
import { ITreeState } from '../ITreeState';
/**
* 添加连接命令
*/
export class AddConnectionCommand extends BaseCommand {
constructor(
private readonly state: ITreeState,
private readonly connection: Connection
) {
super();
}
execute(): void {
const tree = this.state.getTree();
const newTree = tree.addConnection(this.connection);
this.state.setTree(newTree);
}
undo(): void {
const tree = this.state.getTree();
const newTree = tree.removeConnection(
this.connection.from,
this.connection.to,
this.connection.fromProperty,
this.connection.toProperty
);
this.state.setTree(newTree);
}
getDescription(): string {
return `添加连接: ${this.connection.from} -> ${this.connection.to}`;
}
}

View File

@@ -1,34 +0,0 @@
import { Node } from '../../../domain/models/Node';
import { BaseCommand } from '../BaseCommand';
import { ITreeState } from '../ITreeState';
/**
* 创建节点命令
*/
export class CreateNodeCommand extends BaseCommand {
private createdNodeId: string;
constructor(
private readonly state: ITreeState,
private readonly node: Node
) {
super();
this.createdNodeId = node.id;
}
execute(): void {
const tree = this.state.getTree();
const newTree = tree.addNode(this.node);
this.state.setTree(newTree);
}
undo(): void {
const tree = this.state.getTree();
const newTree = tree.removeNode(this.createdNodeId);
this.state.setTree(newTree);
}
getDescription(): string {
return `创建节点: ${this.node.template.displayName}`;
}
}

View File

@@ -1,38 +0,0 @@
import { Node } from '../../../domain/models/Node';
import { BaseCommand } from '../BaseCommand';
import { ITreeState } from '../ITreeState';
/**
* 删除节点命令
*/
export class DeleteNodeCommand extends BaseCommand {
private deletedNode: Node | null = null;
constructor(
private readonly state: ITreeState,
private readonly nodeId: string
) {
super();
}
execute(): void {
const tree = this.state.getTree();
this.deletedNode = tree.getNode(this.nodeId);
const newTree = tree.removeNode(this.nodeId);
this.state.setTree(newTree);
}
undo(): void {
if (!this.deletedNode) {
throw new Error('无法撤销:未保存已删除的节点');
}
const tree = this.state.getTree();
const newTree = tree.addNode(this.deletedNode);
this.state.setTree(newTree);
}
getDescription(): string {
return `删除节点: ${this.deletedNode?.template.displayName ?? this.nodeId}`;
}
}

View File

@@ -1,75 +0,0 @@
import { Position } from '../../../domain/value-objects/Position';
import { BaseCommand } from '../BaseCommand';
import { ITreeState } from '../ITreeState';
import { ICommand } from '../ICommand';
/**
* 移动节点命令
* 支持合并连续的移动操作
*/
export class MoveNodeCommand extends BaseCommand {
private oldPosition: Position;
constructor(
private readonly state: ITreeState,
private readonly nodeId: string,
private readonly newPosition: Position
) {
super();
const tree = this.state.getTree();
const node = tree.getNode(nodeId);
this.oldPosition = node.position;
}
execute(): void {
const tree = this.state.getTree();
const newTree = tree.updateNode(this.nodeId, (node) =>
node.moveToPosition(this.newPosition)
);
this.state.setTree(newTree);
}
undo(): void {
const tree = this.state.getTree();
const newTree = tree.updateNode(this.nodeId, (node) =>
node.moveToPosition(this.oldPosition)
);
this.state.setTree(newTree);
}
getDescription(): string {
return `移动节点: ${this.nodeId}`;
}
/**
* 移动命令可以合并
*/
canMergeWith(other: ICommand): boolean {
if (!(other instanceof MoveNodeCommand)) {
return false;
}
return this.nodeId === other.nodeId;
}
/**
* 合并移动命令
* 保留初始位置,更新最终位置
*/
mergeWith(other: ICommand): ICommand {
if (!(other instanceof MoveNodeCommand)) {
throw new Error('只能与 MoveNodeCommand 合并');
}
if (this.nodeId !== other.nodeId) {
throw new Error('只能合并同一节点的移动命令');
}
const merged = new MoveNodeCommand(
this.state,
this.nodeId,
other.newPosition
);
merged.oldPosition = this.oldPosition;
return merged;
}
}

View File

@@ -1,50 +0,0 @@
import { Connection } from '../../../domain/models/Connection';
import { BaseCommand } from '../BaseCommand';
import { ITreeState } from '../ITreeState';
/**
* 移除连接命令
*/
export class RemoveConnectionCommand extends BaseCommand {
private removedConnection: Connection | null = null;
constructor(
private readonly state: ITreeState,
private readonly from: string,
private readonly to: string,
private readonly fromProperty?: string,
private readonly toProperty?: string
) {
super();
}
execute(): void {
const tree = this.state.getTree();
const connection = tree.connections.find((c) =>
c.matches(this.from, this.to, this.fromProperty, this.toProperty)
);
if (!connection) {
throw new Error(`连接不存在: ${this.from} -> ${this.to}`);
}
this.removedConnection = connection;
const newTree = tree.removeConnection(this.from, this.to, this.fromProperty, this.toProperty);
this.state.setTree(newTree);
}
undo(): void {
if (!this.removedConnection) {
throw new Error('无法撤销:未保存已删除的连接');
}
const tree = this.state.getTree();
const newTree = tree.addConnection(this.removedConnection);
this.state.setTree(newTree);
}
getDescription(): string {
return `移除连接: ${this.from} -> ${this.to}`;
}
}

View File

@@ -1,40 +0,0 @@
import { BaseCommand } from '../BaseCommand';
import { ITreeState } from '../ITreeState';
/**
* 更新节点数据命令
*/
export class UpdateNodeDataCommand extends BaseCommand {
private oldData: Record<string, unknown>;
constructor(
private readonly state: ITreeState,
private readonly nodeId: string,
private readonly newData: Record<string, unknown>
) {
super();
const tree = this.state.getTree();
const node = tree.getNode(nodeId);
this.oldData = node.data;
}
execute(): void {
const tree = this.state.getTree();
const newTree = tree.updateNode(this.nodeId, (node) =>
node.updateData(this.newData)
);
this.state.setTree(newTree);
}
undo(): void {
const tree = this.state.getTree();
const newTree = tree.updateNode(this.nodeId, (node) =>
node.updateData(this.oldData)
);
this.state.setTree(newTree);
}
getDescription(): string {
return `更新节点数据: ${this.nodeId}`;
}
}

View File

@@ -1,6 +0,0 @@
export { CreateNodeCommand } from './CreateNodeCommand';
export { DeleteNodeCommand } from './DeleteNodeCommand';
export { AddConnectionCommand } from './AddConnectionCommand';
export { RemoveConnectionCommand } from './RemoveConnectionCommand';
export { MoveNodeCommand } from './MoveNodeCommand';
export { UpdateNodeDataCommand } from './UpdateNodeDataCommand';

View File

@@ -1,55 +0,0 @@
import { useState } from 'react';
import { BehaviorTreeNode, ROOT_NODE_ID } from '../../stores/behaviorTreeStore';
interface ContextMenuState {
visible: boolean;
position: { x: number; y: number };
nodeId: string | null;
}
export function useContextMenu() {
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
visible: false,
position: { x: 0, y: 0 },
nodeId: null
});
const handleNodeContextMenu = (e: React.MouseEvent, node: BehaviorTreeNode) => {
e.preventDefault();
e.stopPropagation();
// 不允许对Root节点右键
if (node.id === ROOT_NODE_ID) {
return;
}
setContextMenu({
visible: true,
position: { x: e.clientX, y: e.clientY },
nodeId: node.id
});
};
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 });
};
return {
contextMenu,
setContextMenu,
handleNodeContextMenu,
handleCanvasContextMenu,
closeContextMenu
};
}

View File

@@ -1,208 +0,0 @@
import { useState, RefObject } from 'react';
import { NodeTemplate } from '@esengine/behavior-tree';
import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore';
import { Node } from '../../domain/models/Node';
import { Position } from '../../domain/value-objects/Position';
import { useNodeOperations } from '../../presentation/hooks/useNodeOperations';
import { useConnectionOperations } from '../../presentation/hooks/useConnectionOperations';
interface QuickCreateMenuState {
visible: boolean;
position: { x: number; y: number };
searchText: string;
selectedIndex: number;
mode: 'create' | 'replace';
replaceNodeId: string | null;
}
type ExecutionMode = 'idle' | 'running' | 'paused' | 'step';
interface UseQuickCreateMenuParams {
nodeOperations: ReturnType<typeof useNodeOperations>;
connectionOperations: ReturnType<typeof useConnectionOperations>;
canvasRef: RefObject<HTMLDivElement>;
canvasOffset: { x: number; y: number };
canvasScale: number;
connectingFrom: string | null;
connectingFromProperty: string | null;
clearConnecting: () => void;
nodes: BehaviorTreeNode[];
setNodes: (nodes: BehaviorTreeNode[]) => void;
connections: Connection[];
executionMode: ExecutionMode;
onStop: () => void;
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
showToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
export function useQuickCreateMenu(params: UseQuickCreateMenuParams) {
const {
nodeOperations,
connectionOperations,
canvasRef,
canvasOffset,
canvasScale,
connectingFrom,
connectingFromProperty,
clearConnecting,
nodes,
setNodes,
connections,
executionMode,
onStop,
onNodeCreate,
showToast
} = params;
const [quickCreateMenu, setQuickCreateMenu] = useState<QuickCreateMenuState>({
visible: false,
position: { x: 0, y: 0 },
searchText: '',
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
const handleReplaceNode = (newTemplate: NodeTemplate) => {
const nodeToReplace = nodes.find((n) => n.id === quickCreateMenu.replaceNodeId);
if (!nodeToReplace) return;
// 如果行为树正在执行,先停止
if (executionMode !== 'idle') {
onStop();
}
// 合并数据:新模板的默认配置 + 保留旧节点中同名属性的值
const newData = { ...newTemplate.defaultConfig };
// 获取新模板的属性名列表
const newPropertyNames = new Set(newTemplate.properties.map((p) => p.name));
// 遍历旧节点的 data保留新模板中也存在的属性
for (const [key, value] of Object.entries(nodeToReplace.data)) {
// 跳过节点类型相关的字段
if (key === 'nodeType' || key === 'compositeType' || key === 'decoratorType' ||
key === 'actionType' || key === 'conditionType') {
continue;
}
// 如果新模板也有这个属性,保留旧值(包括绑定信息)
if (newPropertyNames.has(key)) {
newData[key] = value;
}
}
// 创建新节点,保留原节点的位置和连接
const newNode = new Node(
nodeToReplace.id,
newTemplate,
newData,
nodeToReplace.position,
Array.from(nodeToReplace.children)
);
// 替换节点
setNodes(nodes.map((n) => n.id === newNode.id ? newNode : n));
// 删除所有指向该节点的属性连接,让用户重新连接
const propertyConnections = connections.filter((conn) =>
conn.connectionType === 'property' && conn.to === newNode.id
);
propertyConnections.forEach((conn) => {
connectionOperations.removeConnection(
conn.from,
conn.to,
conn.fromProperty,
conn.toProperty
);
});
// 关闭快速创建菜单
closeQuickCreateMenu();
// 显示提示
showToast?.(`已将节点替换为 ${newTemplate.displayName}`, 'success');
};
const handleQuickCreateNode = (template: NodeTemplate) => {
// 如果是替换模式,直接调用替换函数
if (quickCreateMenu.mode === 'replace') {
handleReplaceNode(template);
return;
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) {
return;
}
const posX = (quickCreateMenu.position.x - rect.left - canvasOffset.x) / canvasScale;
const posY = (quickCreateMenu.position.y - rect.top - canvasOffset.y) / canvasScale;
const newNode = nodeOperations.createNode(
template,
new Position(posX, posY),
template.defaultConfig
);
// 如果有连接源,创建连接
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');
}
}
}
closeQuickCreateMenu();
onNodeCreate?.(template, { x: posX, y: posY });
};
const openQuickCreateMenu = (
position: { x: number; y: number },
mode: 'create' | 'replace',
replaceNodeId?: string | null
) => {
setQuickCreateMenu({
visible: true,
position,
searchText: '',
selectedIndex: 0,
mode,
replaceNodeId: replaceNodeId || null
});
};
const closeQuickCreateMenu = () => {
setQuickCreateMenu({
visible: false,
position: { x: 0, y: 0 },
searchText: '',
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
clearConnecting();
};
return {
quickCreateMenu,
setQuickCreateMenu,
handleQuickCreateNode,
handleReplaceNode,
openQuickCreateMenu,
closeQuickCreateMenu
};
}

View File

@@ -1,3 +1,2 @@
export * from './commands';
export * from './use-cases';
export * from './state';

View File

@@ -1,250 +0,0 @@
import { NodeTemplate } from '@esengine/behavior-tree';
import { BehaviorTreeNode } from '../../stores/behaviorTreeStore';
import { LucideIcon } from 'lucide-react';
import React from 'react';
export interface INodeRenderer {
canRender(node: BehaviorTreeNode): boolean;
render(node: BehaviorTreeNode, context: NodeRenderContext): React.ReactElement;
}
export interface NodeRenderContext {
isSelected: boolean;
isExecuting: boolean;
onNodeClick: (e: React.MouseEvent, node: BehaviorTreeNode) => void;
onContextMenu: (e: React.MouseEvent, node: BehaviorTreeNode) => void;
}
export interface IPropertyEditor {
canEdit(propertyType: string): boolean;
render(property: PropertyEditorProps): React.ReactElement;
}
export type PropertyValue = string | number | boolean | object | null | undefined;
export interface PropertyEditorProps<T = PropertyValue> {
propertyName: string;
propertyType: string;
value: T;
onChange: (value: T) => void;
config?: Record<string, PropertyValue>;
}
export interface INodeProvider {
getNodeTemplates(): NodeTemplate[];
getCategory(): string;
getIcon(): string | LucideIcon;
}
export interface IToolbarButton {
id: string;
label: string;
icon: LucideIcon;
tooltip?: string;
onClick: () => void;
isVisible?: () => boolean;
isEnabled?: () => boolean;
}
export interface IPanelProvider {
id: string;
title: string;
icon?: LucideIcon;
render(): React.ReactElement;
canActivate?(): boolean;
}
export interface IValidator {
name: string;
validate(nodes: BehaviorTreeNode[]): ValidationResult[];
}
export interface ValidationResult {
severity: 'error' | 'warning' | 'info';
nodeId?: string;
message: string;
code?: string;
}
export interface ICommandProvider {
getCommandId(): string;
getCommandName(): string;
getShortcut?(): string;
canExecute?(): boolean;
execute(context: CommandExecutionContext): void | Promise<void>;
}
export interface CommandExecutionContext {
selectedNodeIds: string[];
nodes: BehaviorTreeNode[];
currentFile?: string;
}
export class EditorExtensionRegistry {
private nodeRenderers: Set<INodeRenderer> = new Set();
private propertyEditors: Set<IPropertyEditor> = new Set();
private nodeProviders: Set<INodeProvider> = new Set();
private toolbarButtons: Set<IToolbarButton> = new Set();
private panelProviders: Set<IPanelProvider> = new Set();
private validators: Set<IValidator> = new Set();
private commandProviders: Set<ICommandProvider> = new Set();
registerNodeRenderer(renderer: INodeRenderer): void {
this.nodeRenderers.add(renderer);
}
unregisterNodeRenderer(renderer: INodeRenderer): void {
this.nodeRenderers.delete(renderer);
}
getNodeRenderer(node: BehaviorTreeNode): INodeRenderer | undefined {
for (const renderer of this.nodeRenderers) {
if (renderer.canRender(node)) {
return renderer;
}
}
return undefined;
}
registerPropertyEditor(editor: IPropertyEditor): void {
this.propertyEditors.add(editor);
}
unregisterPropertyEditor(editor: IPropertyEditor): void {
this.propertyEditors.delete(editor);
}
getPropertyEditor(propertyType: string): IPropertyEditor | undefined {
for (const editor of this.propertyEditors) {
if (editor.canEdit(propertyType)) {
return editor;
}
}
return undefined;
}
registerNodeProvider(provider: INodeProvider): void {
this.nodeProviders.add(provider);
}
unregisterNodeProvider(provider: INodeProvider): void {
this.nodeProviders.delete(provider);
}
getAllNodeTemplates(): NodeTemplate[] {
const templates: NodeTemplate[] = [];
this.nodeProviders.forEach((provider) => {
templates.push(...provider.getNodeTemplates());
});
return templates;
}
registerToolbarButton(button: IToolbarButton): void {
this.toolbarButtons.add(button);
}
unregisterToolbarButton(button: IToolbarButton): void {
this.toolbarButtons.delete(button);
}
getToolbarButtons(): IToolbarButton[] {
return Array.from(this.toolbarButtons).filter((btn) => {
return btn.isVisible ? btn.isVisible() : true;
});
}
registerPanelProvider(provider: IPanelProvider): void {
this.panelProviders.add(provider);
}
unregisterPanelProvider(provider: IPanelProvider): void {
this.panelProviders.delete(provider);
}
getPanelProviders(): IPanelProvider[] {
return Array.from(this.panelProviders).filter((panel) => {
return panel.canActivate ? panel.canActivate() : true;
});
}
registerValidator(validator: IValidator): void {
this.validators.add(validator);
}
unregisterValidator(validator: IValidator): void {
this.validators.delete(validator);
}
async validateTree(nodes: BehaviorTreeNode[]): Promise<ValidationResult[]> {
const results: ValidationResult[] = [];
for (const validator of this.validators) {
try {
const validationResults = validator.validate(nodes);
results.push(...validationResults);
} catch (error) {
console.error(`Error in validator ${validator.name}:`, error);
results.push({
severity: 'error',
message: `Validator ${validator.name} failed: ${error}`,
code: 'VALIDATOR_ERROR'
});
}
}
return results;
}
registerCommandProvider(provider: ICommandProvider): void {
this.commandProviders.add(provider);
}
unregisterCommandProvider(provider: ICommandProvider): void {
this.commandProviders.delete(provider);
}
getCommandProvider(commandId: string): ICommandProvider | undefined {
for (const provider of this.commandProviders) {
if (provider.getCommandId() === commandId) {
return provider;
}
}
return undefined;
}
getAllCommandProviders(): ICommandProvider[] {
return Array.from(this.commandProviders);
}
clear(): void {
this.nodeRenderers.clear();
this.propertyEditors.clear();
this.nodeProviders.clear();
this.toolbarButtons.clear();
this.panelProviders.clear();
this.validators.clear();
this.commandProviders.clear();
}
}
let globalExtensionRegistry: EditorExtensionRegistry | null = null;
export function getGlobalExtensionRegistry(): EditorExtensionRegistry {
if (!globalExtensionRegistry) {
globalExtensionRegistry = new EditorExtensionRegistry();
}
return globalExtensionRegistry;
}
export function resetGlobalExtensionRegistry(): void {
globalExtensionRegistry = null;
}

View File

@@ -1,249 +0,0 @@
import { BehaviorTreeNode, Connection } from '../../stores/behaviorTreeStore';
import { ExecutionLog } from '../../utils/BehaviorTreeExecutor';
import { BlackboardValue } from '../../domain/models/Blackboard';
type BlackboardVariables = Record<string, BlackboardValue>;
type NodeExecutionStatus = 'idle' | 'running' | 'success' | 'failure';
export interface ExecutionContext {
nodes: BehaviorTreeNode[];
connections: Connection[];
blackboardVariables: BlackboardVariables;
rootNodeId: string;
tickCount: number;
}
export interface NodeStatusChangeEvent {
nodeId: string;
status: NodeExecutionStatus;
previousStatus?: NodeExecutionStatus;
timestamp: number;
}
export interface IExecutionHooks {
beforePlay?(context: ExecutionContext): void | Promise<void>;
afterPlay?(context: ExecutionContext): void | Promise<void>;
beforePause?(): void | Promise<void>;
afterPause?(): void | Promise<void>;
beforeResume?(): void | Promise<void>;
afterResume?(): void | Promise<void>;
beforeStop?(): void | Promise<void>;
afterStop?(): void | Promise<void>;
beforeStep?(deltaTime: number): void | Promise<void>;
afterStep?(deltaTime: number): void | Promise<void>;
onTick?(tickCount: number, deltaTime: number): void | Promise<void>;
onNodeStatusChange?(event: NodeStatusChangeEvent): void | Promise<void>;
onExecutionComplete?(logs: ExecutionLog[]): void | Promise<void>;
onBlackboardUpdate?(variables: BlackboardVariables): void | Promise<void>;
onError?(error: Error, context?: string): void | Promise<void>;
}
export class ExecutionHooksManager {
private hooks: Set<IExecutionHooks> = new Set();
register(hook: IExecutionHooks): void {
this.hooks.add(hook);
}
unregister(hook: IExecutionHooks): void {
this.hooks.delete(hook);
}
clear(): void {
this.hooks.clear();
}
async triggerBeforePlay(context: ExecutionContext): Promise<void> {
for (const hook of this.hooks) {
if (hook.beforePlay) {
try {
await hook.beforePlay(context);
} catch (error) {
console.error('Error in beforePlay hook:', error);
}
}
}
}
async triggerAfterPlay(context: ExecutionContext): Promise<void> {
for (const hook of this.hooks) {
if (hook.afterPlay) {
try {
await hook.afterPlay(context);
} catch (error) {
console.error('Error in afterPlay hook:', error);
}
}
}
}
async triggerBeforePause(): Promise<void> {
for (const hook of this.hooks) {
if (hook.beforePause) {
try {
await hook.beforePause();
} catch (error) {
console.error('Error in beforePause hook:', error);
}
}
}
}
async triggerAfterPause(): Promise<void> {
for (const hook of this.hooks) {
if (hook.afterPause) {
try {
await hook.afterPause();
} catch (error) {
console.error('Error in afterPause hook:', error);
}
}
}
}
async triggerBeforeResume(): Promise<void> {
for (const hook of this.hooks) {
if (hook.beforeResume) {
try {
await hook.beforeResume();
} catch (error) {
console.error('Error in beforeResume hook:', error);
}
}
}
}
async triggerAfterResume(): Promise<void> {
for (const hook of this.hooks) {
if (hook.afterResume) {
try {
await hook.afterResume();
} catch (error) {
console.error('Error in afterResume hook:', error);
}
}
}
}
async triggerBeforeStop(): Promise<void> {
for (const hook of this.hooks) {
if (hook.beforeStop) {
try {
await hook.beforeStop();
} catch (error) {
console.error('Error in beforeStop hook:', error);
}
}
}
}
async triggerAfterStop(): Promise<void> {
for (const hook of this.hooks) {
if (hook.afterStop) {
try {
await hook.afterStop();
} catch (error) {
console.error('Error in afterStop hook:', error);
}
}
}
}
async triggerBeforeStep(deltaTime: number): Promise<void> {
for (const hook of this.hooks) {
if (hook.beforeStep) {
try {
await hook.beforeStep(deltaTime);
} catch (error) {
console.error('Error in beforeStep hook:', error);
}
}
}
}
async triggerAfterStep(deltaTime: number): Promise<void> {
for (const hook of this.hooks) {
if (hook.afterStep) {
try {
await hook.afterStep(deltaTime);
} catch (error) {
console.error('Error in afterStep hook:', error);
}
}
}
}
async triggerOnTick(tickCount: number, deltaTime: number): Promise<void> {
for (const hook of this.hooks) {
if (hook.onTick) {
try {
await hook.onTick(tickCount, deltaTime);
} catch (error) {
console.error('Error in onTick hook:', error);
}
}
}
}
async triggerOnNodeStatusChange(event: NodeStatusChangeEvent): Promise<void> {
for (const hook of this.hooks) {
if (hook.onNodeStatusChange) {
try {
await hook.onNodeStatusChange(event);
} catch (error) {
console.error('Error in onNodeStatusChange hook:', error);
}
}
}
}
async triggerOnExecutionComplete(logs: ExecutionLog[]): Promise<void> {
for (const hook of this.hooks) {
if (hook.onExecutionComplete) {
try {
await hook.onExecutionComplete(logs);
} catch (error) {
console.error('Error in onExecutionComplete hook:', error);
}
}
}
}
async triggerOnBlackboardUpdate(variables: BlackboardVariables): Promise<void> {
for (const hook of this.hooks) {
if (hook.onBlackboardUpdate) {
try {
await hook.onBlackboardUpdate(variables);
} catch (error) {
console.error('Error in onBlackboardUpdate hook:', error);
}
}
}
}
async triggerOnError(error: Error, context?: string): Promise<void> {
for (const hook of this.hooks) {
if (hook.onError) {
try {
await hook.onError(error, context);
} catch (err) {
console.error('Error in onError hook:', err);
}
}
}
}
}

View File

@@ -1,42 +0,0 @@
import { BlackboardValue } from '../../domain/models/Blackboard';
type BlackboardVariables = Record<string, BlackboardValue>;
export class BlackboardManager {
private initialVariables: BlackboardVariables = {};
private currentVariables: BlackboardVariables = {};
setInitialVariables(variables: BlackboardVariables): void {
this.initialVariables = JSON.parse(JSON.stringify(variables)) as BlackboardVariables;
}
getInitialVariables(): BlackboardVariables {
return { ...this.initialVariables };
}
setCurrentVariables(variables: BlackboardVariables): void {
this.currentVariables = { ...variables };
}
getCurrentVariables(): BlackboardVariables {
return { ...this.currentVariables };
}
updateVariable(key: string, value: BlackboardValue): void {
this.currentVariables[key] = value;
}
restoreInitialVariables(): BlackboardVariables {
this.currentVariables = { ...this.initialVariables };
return this.getInitialVariables();
}
hasChanges(): boolean {
return JSON.stringify(this.currentVariables) !== JSON.stringify(this.initialVariables);
}
clear(): void {
this.initialVariables = {};
this.currentVariables = {};
}
}

View File

@@ -1,457 +0,0 @@
import { BehaviorTreeExecutor, ExecutionStatus, ExecutionLog } from '../../utils/BehaviorTreeExecutor';
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 BlackboardVariables = Record<string, BlackboardValue>;
interface ExecutionControllerConfig {
rootNodeId: string;
projectPath: string | null;
onLogsUpdate: (logs: ExecutionLog[]) => void;
onBlackboardUpdate: (variables: BlackboardVariables) => void;
onTickCountUpdate: (count: number) => void;
onExecutionStatusUpdate: (statuses: Map<string, NodeExecutionStatus>, orders: Map<string, number>) => void;
eventBus?: EditorEventBus;
hooksManager?: ExecutionHooksManager;
}
export class ExecutionController {
private executor: BehaviorTreeExecutor | null = null;
private mode: ExecutionMode = 'idle';
private animationFrameId: number | null = null;
private lastTickTime: number = 0;
private speed: number = 1.0;
private tickCount: number = 0;
private domCache: DOMCache = new DOMCache();
private eventBus?: EditorEventBus;
private hooksManager?: ExecutionHooksManager;
private config: ExecutionControllerConfig;
private currentNodes: BehaviorTreeNode[] = [];
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();
this.eventBus = config.eventBus;
this.hooksManager = config.hooksManager;
}
getMode(): ExecutionMode {
return this.mode;
}
getTickCount(): number {
return this.tickCount;
}
getSpeed(): number {
return this.speed;
}
setSpeed(speed: number): void {
this.speed = speed;
this.lastTickTime = 0;
}
async play(
nodes: BehaviorTreeNode[],
blackboardVariables: BlackboardVariables,
connections: Connection[]
): Promise<void> {
if (this.mode === 'running') return;
this.currentNodes = nodes;
this.currentConnections = connections;
this.currentBlackboard = blackboardVariables;
const context = {
nodes,
connections,
blackboardVariables,
rootNodeId: this.config.rootNodeId,
tickCount: 0
};
try {
await this.hooksManager?.triggerBeforePlay(context);
this.mode = 'running';
this.tickCount = 0;
this.lastTickTime = 0;
if (!this.executor) {
this.executor = new BehaviorTreeExecutor();
}
this.executor.buildTree(
nodes,
this.config.rootNodeId,
blackboardVariables,
connections,
this.handleExecutionStatusUpdate.bind(this)
);
this.executor.start();
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
this.eventBus?.emit(EditorEvent.EXECUTION_STARTED, context);
await this.hooksManager?.triggerAfterPlay(context);
} catch (error) {
console.error('Error in play:', error);
await this.hooksManager?.triggerOnError(error as Error, 'play');
throw error;
}
}
async pause(): Promise<void> {
try {
if (this.mode === 'running') {
await this.hooksManager?.triggerBeforePause();
this.mode = 'paused';
if (this.executor) {
this.executor.pause();
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.eventBus?.emit(EditorEvent.EXECUTION_PAUSED);
await this.hooksManager?.triggerAfterPause();
} else if (this.mode === 'paused') {
await this.hooksManager?.triggerBeforeResume();
this.mode = 'running';
this.lastTickTime = 0;
if (this.executor) {
this.executor.resume();
}
this.animationFrameId = requestAnimationFrame(this.tickLoop.bind(this));
this.eventBus?.emit(EditorEvent.EXECUTION_RESUMED);
await this.hooksManager?.triggerAfterResume();
}
} catch (error) {
console.error('Error in pause/resume:', error);
await this.hooksManager?.triggerOnError(error as Error, 'pause');
throw error;
}
}
async stop(): Promise<void> {
try {
await this.hooksManager?.triggerBeforeStop();
this.mode = 'idle';
this.tickCount = 0;
this.lastTickTime = 0;
this.lastStepTime = 0;
this.pendingStatusUpdates = [];
this.currentlyDisplayedIndex = 0;
this.domCache.clearAllStatusTimers();
this.domCache.clearStatusCache();
this.config.onExecutionStatusUpdate(new Map(), new Map());
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.executor) {
this.executor.stop();
}
this.eventBus?.emit(EditorEvent.EXECUTION_STOPPED);
await this.hooksManager?.triggerAfterStop();
} catch (error) {
console.error('Error in stop:', error);
await this.hooksManager?.triggerOnError(error as Error, 'stop');
throw error;
}
}
async reset(): Promise<void> {
await this.stop();
if (this.executor) {
this.executor.cleanup();
}
}
step(): void {
// 单步执行功能预留
}
updateBlackboardVariable(key: string, value: BlackboardValue): void {
if (this.executor && this.mode !== 'idle') {
this.executor.updateBlackboardVariable(key, value);
}
}
getBlackboardVariables(): BlackboardVariables {
if (this.executor) {
return this.executor.getBlackboardVariables();
}
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();
}
destroy(): void {
this.stop();
if (this.executor) {
this.executor.destroy();
this.executor = null;
}
}
private tickLoop(currentTime: number): void {
if (this.mode !== 'running') {
return;
}
if (!this.executor) {
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 scaledTickInterval = baseTickInterval / this.speed;
if (this.lastTickTime === 0) {
this.lastTickTime = currentTime;
}
const elapsed = currentTime - this.lastTickTime;
if (elapsed >= scaledTickInterval) {
const deltaTime = baseTickInterval / 1000;
this.executor!.tick(deltaTime);
this.tickCount = this.executor!.getTickCount();
this.config.onTickCountUpdate(this.tickCount);
this.lastTickTime = currentTime;
}
}
private handleStepByStepExecution(currentTime: number): void {
if (this.lastStepTime === 0) {
this.lastStepTime = currentTime;
}
const stepElapsed = currentTime - this.lastStepTime;
const actualStepInterval = this.stepInterval / this.speed;
if (stepElapsed >= actualStepInterval) {
if (this.currentlyDisplayedIndex < this.pendingStatusUpdates.length) {
this.displayNextNode();
this.lastStepTime = currentTime;
} else {
if (this.lastTickTime === 0) {
this.lastTickTime = currentTime;
}
const tickElapsed = currentTime - this.lastTickTime;
const baseTickInterval = 16.67;
const scaledTickInterval = baseTickInterval / this.speed;
if (tickElapsed >= scaledTickInterval) {
const deltaTime = baseTickInterval / 1000;
this.executor!.tick(deltaTime);
this.tickCount = this.executor!.getTickCount();
this.config.onTickCountUpdate(this.tickCount);
this.lastTickTime = currentTime;
}
}
}
}
private displayNextNode(): void {
if (this.currentlyDisplayedIndex >= this.pendingStatusUpdates.length) {
return;
}
const statusesToDisplay = this.pendingStatusUpdates.slice(0, this.currentlyDisplayedIndex + 1);
const currentNode = this.pendingStatusUpdates[this.currentlyDisplayedIndex];
if (!currentNode) {
return;
}
const statusMap = new Map<string, NodeExecutionStatus>();
const orderMap = new Map<string, number>();
statusesToDisplay.forEach((s) => {
statusMap.set(s.nodeId, s.status);
if (s.executionOrder !== undefined) {
orderMap.set(s.nodeId, s.executionOrder);
}
});
const nodeName = this.currentNodes.find(n => n.id === currentNode.nodeId)?.template.displayName || 'Unknown';
console.log(`[StepByStep] Displaying ${this.currentlyDisplayedIndex + 1}/${this.pendingStatusUpdates.length} | ${nodeName} | Order: ${currentNode.executionOrder} | ID: ${currentNode.nodeId}`);
this.config.onExecutionStatusUpdate(statusMap, orderMap);
this.currentlyDisplayedIndex++;
}
private handleExecutionStatusUpdate(
statuses: ExecutionStatus[],
logs: ExecutionLog[],
runtimeBlackboardVars?: BlackboardVariables
): void {
this.config.onLogsUpdate([...logs]);
if (runtimeBlackboardVars) {
this.config.onBlackboardUpdate(runtimeBlackboardVars);
}
if (this.stepByStepMode) {
const statusesWithOrder = statuses.filter(s => s.executionOrder !== undefined);
if (statusesWithOrder.length > 0) {
const minOrder = Math.min(...statusesWithOrder.map(s => s.executionOrder!));
if (minOrder === 1 || this.pendingStatusUpdates.length === 0) {
this.pendingStatusUpdates = statusesWithOrder.sort((a, b) =>
(a.executionOrder || 0) - (b.executionOrder || 0)
);
this.currentlyDisplayedIndex = 0;
this.lastStepTime = 0;
} else {
const maxExistingOrder = this.pendingStatusUpdates.length > 0
? Math.max(...this.pendingStatusUpdates.map(s => s.executionOrder || 0))
: 0;
const newStatuses = statusesWithOrder.filter(s =>
(s.executionOrder || 0) > maxExistingOrder
);
if (newStatuses.length > 0) {
console.log(`[StepByStep] Appending ${newStatuses.length} new nodes, orders:`, newStatuses.map(s => s.executionOrder));
this.pendingStatusUpdates = [
...this.pendingStatusUpdates,
...newStatuses
].sort((a, b) => (a.executionOrder || 0) - (b.executionOrder || 0));
}
}
}
} else {
const statusMap = new Map<string, NodeExecutionStatus>();
const orderMap = new Map<string, number>();
statuses.forEach((s) => {
statusMap.set(s.nodeId, s.status);
if (s.executionOrder !== undefined) {
orderMap.set(s.nodeId, s.executionOrder);
}
});
this.config.onExecutionStatusUpdate(statusMap, orderMap);
}
}
private updateConnectionStyles(
statusMap: Record<string, NodeExecutionStatus>,
connections?: Connection[]
): void {
if (!connections) return;
connections.forEach((conn) => {
const connKey = `${conn.from}-${conn.to}`;
const pathElement = this.domCache.getConnection(connKey);
if (!pathElement) {
return;
}
const fromStatus = statusMap[conn.from];
const toStatus = statusMap[conn.to];
const isActive = fromStatus === 'running' || toStatus === 'running';
if (conn.connectionType === 'property') {
this.domCache.setConnectionAttribute(connKey, 'stroke', '#9c27b0');
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2');
} else if (isActive) {
this.domCache.setConnectionAttribute(connKey, 'stroke', '#ffa726');
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '3');
} else {
const isExecuted = this.domCache.hasNodeClass(conn.from, 'executed') &&
this.domCache.hasNodeClass(conn.to, 'executed');
if (isExecuted) {
this.domCache.setConnectionAttribute(connKey, 'stroke', '#4caf50');
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2.5');
} else {
this.domCache.setConnectionAttribute(connKey, 'stroke', '#0e639c');
this.domCache.setConnectionAttribute(connKey, 'stroke-width', '2');
}
}
});
}
setConnections(connections: Connection[]): void {
if (this.mode !== 'idle') {
const currentStatuses: Record<string, NodeExecutionStatus> = {};
connections.forEach((conn) => {
const fromStatus = this.domCache.getLastStatus(conn.from);
const toStatus = this.domCache.getLastStatus(conn.to);
if (fromStatus) currentStatuses[conn.from] = fromStatus;
if (toStatus) currentStatuses[conn.to] = toStatus;
});
this.updateConnectionStyles(currentStatuses, connections);
}
}
}

View File

@@ -1,65 +0,0 @@
import { create } from 'zustand';
import { BehaviorTree } from '../../domain/models/BehaviorTree';
import { ITreeState } from '../commands/ITreeState';
import { useBehaviorTreeStore } from '../../stores/behaviorTreeStore';
import { Blackboard } from '../../domain/models/Blackboard';
import { createRootNode, ROOT_NODE_ID } from '../../domain/constants/RootNode';
const createInitialTree = (): BehaviorTree => {
const rootNode = createRootNode();
return new BehaviorTree([rootNode], [], Blackboard.empty(), ROOT_NODE_ID);
};
/**
* 行为树数据状态
* 管理核心业务数据
*/
interface BehaviorTreeDataState {
/**
* 当前行为树
*/
tree: BehaviorTree;
/**
* 设置行为树
*/
setTree: (tree: BehaviorTree) => void;
/**
* 重置为空树
*/
reset: () => void;
}
/**
* 行为树数据 Store
* 实现 ITreeState 接口,供命令使用
*/
export const useBehaviorTreeDataStore = create<BehaviorTreeDataState>((set) => ({
tree: createInitialTree(),
setTree: (tree: BehaviorTree) => set({ tree }),
reset: () => set({ tree: createInitialTree() })
}));
/**
* TreeState 适配器
* 将 Zustand Store 适配为 ITreeState 接口
* 同步更新领域层和表现层的状态
*/
export class TreeStateAdapter implements ITreeState {
getTree(): BehaviorTree {
return useBehaviorTreeDataStore.getState().tree;
}
setTree(tree: BehaviorTree): void {
useBehaviorTreeDataStore.getState().setTree(tree);
const nodes = Array.from(tree.nodes);
const connections = Array.from(tree.connections);
useBehaviorTreeStore.getState().setNodes(nodes);
useBehaviorTreeStore.getState().setConnections(connections);
}
}

View File

@@ -1,3 +1,2 @@
export { useBehaviorTreeDataStore, TreeStateAdapter } from './BehaviorTreeDataStore';
export { useUIStore } from './UIStore';
export { useEditorStore } from './EditorStore';

View File

@@ -1,42 +0,0 @@
import { Connection, ConnectionType } from '../../domain/models/Connection';
import { CommandManager } from '../commands/CommandManager';
import { AddConnectionCommand } from '../commands/tree/AddConnectionCommand';
import { ITreeState } from '../commands/ITreeState';
import { IValidator } from '../../domain/interfaces/IValidator';
/**
* 添加连接用例
*/
export class AddConnectionUseCase {
constructor(
private readonly commandManager: CommandManager,
private readonly treeState: ITreeState,
private readonly validator: IValidator
) {}
/**
* 执行添加连接操作
*/
execute(
from: string,
to: string,
connectionType: ConnectionType = 'node',
fromProperty?: string,
toProperty?: string
): Connection {
const connection = new Connection(from, to, connectionType, fromProperty, toProperty);
const tree = this.treeState.getTree();
const validationResult = this.validator.validateConnection(connection, tree);
if (!validationResult.isValid) {
const errorMessages = validationResult.errors.map((e) => e.message).join(', ');
throw new Error(`连接验证失败: ${errorMessages}`);
}
const command = new AddConnectionCommand(this.treeState, connection);
this.commandManager.execute(command);
return connection;
}
}

View File

@@ -1,42 +0,0 @@
import { NodeTemplate } from '@esengine/behavior-tree';
import { Node } from '../../domain/models/Node';
import { Position } from '../../domain/value-objects/Position';
import { INodeFactory } from '../../domain/interfaces/INodeFactory';
import { CommandManager } from '../commands/CommandManager';
import { CreateNodeCommand } from '../commands/tree/CreateNodeCommand';
import { ITreeState } from '../commands/ITreeState';
/**
* 创建节点用例
*/
export class CreateNodeUseCase {
constructor(
private readonly nodeFactory: INodeFactory,
private readonly commandManager: CommandManager,
private readonly treeState: ITreeState
) {}
/**
* 执行创建节点操作
*/
execute(template: NodeTemplate, position: Position, data?: Record<string, unknown>): Node {
const node = this.nodeFactory.createNode(template, position, data);
const command = new CreateNodeCommand(this.treeState, node);
this.commandManager.execute(command);
return node;
}
/**
* 根据类型创建节点
*/
executeByType(nodeType: string, position: Position, data?: Record<string, unknown>): Node {
const node = this.nodeFactory.createNodeByType(nodeType, position, data);
const command = new CreateNodeCommand(this.treeState, node);
this.commandManager.execute(command);
return node;
}
}

View File

@@ -1,77 +0,0 @@
import { CommandManager } from '../commands/CommandManager';
import { DeleteNodeCommand } from '../commands/tree/DeleteNodeCommand';
import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand';
import { ITreeState } from '../commands/ITreeState';
import { ICommand } from '../commands/ICommand';
/**
* 删除节点用例
* 删除节点时会自动删除相关连接
*/
export class DeleteNodeUseCase {
constructor(
private readonly commandManager: CommandManager,
private readonly treeState: ITreeState
) {}
/**
* 删除单个节点
*/
execute(nodeId: string): void {
const tree = this.treeState.getTree();
const relatedConnections = tree.connections.filter(
(conn) => conn.from === nodeId || conn.to === nodeId
);
const commands: ICommand[] = [];
relatedConnections.forEach((conn) => {
commands.push(
new RemoveConnectionCommand(
this.treeState,
conn.from,
conn.to,
conn.fromProperty,
conn.toProperty
)
);
});
commands.push(new DeleteNodeCommand(this.treeState, nodeId));
this.commandManager.executeBatch(commands);
}
/**
* 批量删除节点
*/
executeBatch(nodeIds: string[]): void {
const tree = this.treeState.getTree();
const commands: ICommand[] = [];
const nodeIdSet = new Set(nodeIds);
const relatedConnections = tree.connections.filter(
(conn) => nodeIdSet.has(conn.from) || nodeIdSet.has(conn.to)
);
relatedConnections.forEach((conn) => {
commands.push(
new RemoveConnectionCommand(
this.treeState,
conn.from,
conn.to,
conn.fromProperty,
conn.toProperty
)
);
});
nodeIds.forEach((nodeId) => {
commands.push(new DeleteNodeCommand(this.treeState, nodeId));
});
this.commandManager.executeBatch(commands);
}
}

View File

@@ -1,32 +0,0 @@
import { Position } from '../../domain/value-objects/Position';
import { CommandManager } from '../commands/CommandManager';
import { MoveNodeCommand } from '../commands/tree/MoveNodeCommand';
import { ITreeState } from '../commands/ITreeState';
/**
* 移动节点用例
*/
export class MoveNodeUseCase {
constructor(
private readonly commandManager: CommandManager,
private readonly treeState: ITreeState
) {}
/**
* 移动单个节点
*/
execute(nodeId: string, newPosition: Position): void {
const command = new MoveNodeCommand(this.treeState, nodeId, newPosition);
this.commandManager.execute(command);
}
/**
* 批量移动节点
*/
executeBatch(moves: Array<{ nodeId: string; position: Position }>): void {
const commands = moves.map(
({ nodeId, position }) => new MoveNodeCommand(this.treeState, nodeId, position)
);
this.commandManager.executeBatch(commands);
}
}

View File

@@ -1,27 +0,0 @@
import { CommandManager } from '../commands/CommandManager';
import { RemoveConnectionCommand } from '../commands/tree/RemoveConnectionCommand';
import { ITreeState } from '../commands/ITreeState';
/**
* 移除连接用例
*/
export class RemoveConnectionUseCase {
constructor(
private readonly commandManager: CommandManager,
private readonly treeState: ITreeState
) {}
/**
* 执行移除连接操作
*/
execute(from: string, to: string, fromProperty?: string, toProperty?: string): void {
const command = new RemoveConnectionCommand(
this.treeState,
from,
to,
fromProperty,
toProperty
);
this.commandManager.execute(command);
}
}

View File

@@ -1,21 +0,0 @@
import { CommandManager } from '../commands/CommandManager';
import { UpdateNodeDataCommand } from '../commands/tree/UpdateNodeDataCommand';
import { ITreeState } from '../commands/ITreeState';
/**
* 更新节点数据用例
*/
export class UpdateNodeDataUseCase {
constructor(
private readonly commandManager: CommandManager,
private readonly treeState: ITreeState
) {}
/**
* 更新节点数据
*/
execute(nodeId: string, data: Record<string, unknown>): void {
const command = new UpdateNodeDataCommand(this.treeState, nodeId, data);
this.commandManager.execute(command);
}
}

View File

@@ -1,32 +0,0 @@
import { IValidator, ValidationResult } from '../../domain/interfaces/IValidator';
import { ITreeState } from '../commands/ITreeState';
/**
* 验证行为树用例
*/
export class ValidateTreeUseCase {
constructor(
private readonly validator: IValidator,
private readonly treeState: ITreeState
) {}
/**
* 验证当前行为树
*/
execute(): ValidationResult {
const tree = this.treeState.getTree();
return this.validator.validateTree(tree);
}
/**
* 验证并抛出错误(如果验证失败)
*/
executeAndThrow(): void {
const result = this.execute();
if (!result.isValid) {
const errorMessages = result.errors.map((e) => e.message).join('\n');
throw new Error(`行为树验证失败:\n${errorMessages}`);
}
}
}

View File

@@ -1,7 +0,0 @@
export { CreateNodeUseCase } from './CreateNodeUseCase';
export { DeleteNodeUseCase } from './DeleteNodeUseCase';
export { AddConnectionUseCase } from './AddConnectionUseCase';
export { RemoveConnectionUseCase } from './RemoveConnectionUseCase';
export { MoveNodeUseCase } from './MoveNodeUseCase';
export { UpdateNodeDataUseCase } from './UpdateNodeDataUseCase';
export { ValidateTreeUseCase } from './ValidateTreeUseCase';

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3, LayoutGrid, List, ChevronsUp } from 'lucide-react';
import { Folder, File, FileCode, FileJson, FileImage, FileText, FolderOpen, Copy, Trash2, Edit3, LayoutGrid, List, ChevronsUp, RefreshCw } from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub, FileActionRegistry } from '@esengine/editor-core';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
@@ -43,6 +43,11 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
position: { x: number; y: number };
asset: AssetItem;
} | null>(null);
const [renameDialog, setRenameDialog] = useState<{
asset: AssetItem;
newName: string;
} | null>(null);
const [deleteConfirmDialog, setDeleteConfirmDialog] = useState<AssetItem | null>(null);
const translations = {
en: {
@@ -251,6 +256,61 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
}
};
const handleRename = async (asset: AssetItem, newName: string) => {
if (!newName.trim() || newName === asset.name) {
setRenameDialog(null);
return;
}
try {
const lastSlash = Math.max(asset.path.lastIndexOf('/'), asset.path.lastIndexOf('\\'));
const parentPath = asset.path.substring(0, lastSlash);
const newPath = `${parentPath}/${newName}`;
await TauriAPI.renameFileOrFolder(asset.path, newPath);
// 刷新当前目录
if (currentPath) {
await loadAssets(currentPath);
}
// 更新选中路径
if (selectedPath === asset.path) {
setSelectedPath(newPath);
}
setRenameDialog(null);
} catch (error) {
console.error('Failed to rename:', error);
alert(`重命名失败: ${error}`);
}
};
const handleDelete = async (asset: AssetItem) => {
try {
if (asset.type === 'folder') {
await TauriAPI.deleteFolder(asset.path);
} else {
await TauriAPI.deleteFile(asset.path);
}
// 刷新当前目录
if (currentPath) {
await loadAssets(currentPath);
}
// 清除选中状态
if (selectedPath === asset.path) {
setSelectedPath(null);
}
setDeleteConfirmDialog(null);
} catch (error) {
console.error('Failed to delete:', error);
alert(`删除失败: ${error}`);
}
};
const handleContextMenu = (e: React.MouseEvent, asset: AssetItem) => {
e.preventDefault();
setContextMenu({
@@ -262,7 +322,6 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
const getContextMenuItems = (asset: AssetItem): ContextMenuItem[] => {
const items: ContextMenuItem[] = [];
// 打开
if (asset.type === 'file') {
items.push({
label: locale === 'zh' ? '打开' : 'Open',
@@ -292,6 +351,28 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
items.push({ label: '', separator: true, onClick: () => {} });
}
if (asset.type === 'folder' && fileActionRegistry) {
const templates = fileActionRegistry.getCreationTemplates();
if (templates.length > 0) {
items.push({ label: '', separator: true, onClick: () => {} });
for (const template of templates) {
items.push({
label: `${locale === 'zh' ? '新建' : 'New'} ${template.label}`,
icon: template.icon,
onClick: async () => {
const fileName = `${template.defaultFileName}.${template.extension}`;
const filePath = `${asset.path}/${fileName}`;
const content = await template.createContent(fileName);
await TauriAPI.writeFileContent(filePath, content);
if (currentPath) {
await loadAssets(currentPath);
}
}
});
}
}
}
// 在文件管理器中显示
items.push({
label: locale === 'zh' ? '在文件管理器中显示' : 'Show in Explorer',
@@ -323,10 +404,13 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
label: locale === 'zh' ? '重命名' : 'Rename',
icon: <Edit3 size={16} />,
onClick: () => {
// TODO: 实现重命名功能
console.log('Rename:', asset.path);
setRenameDialog({
asset,
newName: asset.name
});
setContextMenu(null);
},
disabled: true
disabled: false
});
// 删除
@@ -334,10 +418,10 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
label: locale === 'zh' ? '删除' : 'Delete',
icon: <Trash2 size={16} />,
onClick: () => {
// TODO: 实现删除功能
console.log('Delete:', asset.path);
setDeleteConfirmDialog(asset);
setContextMenu(null);
},
disabled: true
disabled: false
});
return items;
@@ -482,6 +566,39 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
>
<ChevronsUp size={14} />
</button>
<button
onClick={() => {
if (currentPath) {
loadAssets(currentPath);
}
if (showDetailView) {
detailViewFileTreeRef.current?.refresh();
} else {
treeOnlyViewFileTreeRef.current?.refresh();
}
}}
style={{
padding: '6px 8px',
background: 'transparent',
border: '1px solid #3e3e3e',
color: '#cccccc',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '3px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#2a2d2e';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
title="刷新资产列表"
>
<RefreshCw size={14} />
</button>
<input
type="text"
className="asset-search"
@@ -605,6 +722,117 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
onClose={() => setContextMenu(null)}
/>
)}
{/* 重命名对话框 */}
{renameDialog && (
<div className="dialog-overlay" onClick={() => setRenameDialog(null)}>
<div className="dialog-content" onClick={(e) => e.stopPropagation()}>
<div className="dialog-header">
<h3>{locale === 'zh' ? '重命名' : 'Rename'}</h3>
</div>
<div className="dialog-body">
<input
type="text"
value={renameDialog.newName}
onChange={(e) => setRenameDialog({ ...renameDialog, newName: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRename(renameDialog.asset, renameDialog.newName);
} else if (e.key === 'Escape') {
setRenameDialog(null);
}
}}
autoFocus
style={{
width: '100%',
padding: '8px',
backgroundColor: '#2d2d2d',
border: '1px solid #3e3e3e',
borderRadius: '4px',
color: '#cccccc',
fontSize: '13px'
}}
/>
</div>
<div className="dialog-footer">
<button
onClick={() => setRenameDialog(null)}
style={{
padding: '6px 16px',
backgroundColor: '#3e3e3e',
border: 'none',
borderRadius: '4px',
color: '#cccccc',
cursor: 'pointer',
marginRight: '8px'
}}
>
{locale === 'zh' ? '取消' : 'Cancel'}
</button>
<button
onClick={() => handleRename(renameDialog.asset, renameDialog.newName)}
style={{
padding: '6px 16px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#ffffff',
cursor: 'pointer'
}}
>
{locale === 'zh' ? '确定' : 'OK'}
</button>
</div>
</div>
</div>
)}
{/* 删除确认对话框 */}
{deleteConfirmDialog && (
<div className="dialog-overlay" onClick={() => setDeleteConfirmDialog(null)}>
<div className="dialog-content" onClick={(e) => e.stopPropagation()}>
<div className="dialog-header">
<h3>{locale === 'zh' ? '确认删除' : 'Confirm Delete'}</h3>
</div>
<div className="dialog-body">
<p style={{ margin: 0, color: '#cccccc' }}>
{locale === 'zh'
? `确定要删除 "${deleteConfirmDialog.name}" 吗?此操作不可撤销。`
: `Are you sure you want to delete "${deleteConfirmDialog.name}"? This action cannot be undone.`}
</p>
</div>
<div className="dialog-footer">
<button
onClick={() => setDeleteConfirmDialog(null)}
style={{
padding: '6px 16px',
backgroundColor: '#3e3e3e',
border: 'none',
borderRadius: '4px',
color: '#cccccc',
cursor: 'pointer',
marginRight: '8px'
}}
>
{locale === 'zh' ? '取消' : 'Cancel'}
</button>
<button
onClick={() => handleDelete(deleteConfirmDialog)}
style={{
padding: '6px 16px',
backgroundColor: '#c53030',
border: 'none',
borderRadius: '4px',
color: '#ffffff',
cursor: 'pointer'
}}
>
{locale === 'zh' ? '删除' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

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

View File

@@ -1,669 +0,0 @@
import React, { useEffect, useMemo } from 'react';
import { NodeTemplate } from '@esengine/behavior-tree';
import { RotateCcw } from 'lucide-react';
import { useBehaviorTreeStore, BehaviorTreeNode, ROOT_NODE_ID } from '../stores/behaviorTreeStore';
import { useUIStore } from '../application/state/UIStore';
import { useToast } from './Toast';
import { BlackboardValue } from '../domain/models/Blackboard';
import { BehaviorTreeCanvas } from '../presentation/components/behavior-tree/canvas/BehaviorTreeCanvas';
import { ConnectionLayer } from '../presentation/components/behavior-tree/connections/ConnectionLayer';
import { NodeFactory } from '../infrastructure/factories/NodeFactory';
import { BehaviorTreeValidator } from '../infrastructure/validation/BehaviorTreeValidator';
import { useNodeOperations } from '../presentation/hooks/useNodeOperations';
import { useConnectionOperations } from '../presentation/hooks/useConnectionOperations';
import { useCommandHistory } from '../presentation/hooks/useCommandHistory';
import { useNodeDrag } from '../presentation/hooks/useNodeDrag';
import { usePortConnection } from '../presentation/hooks/usePortConnection';
import { useKeyboardShortcuts } from '../presentation/hooks/useKeyboardShortcuts';
import { useDropHandler } from '../presentation/hooks/useDropHandler';
import { useCanvasMouseEvents } from '../presentation/hooks/useCanvasMouseEvents';
import { useContextMenu } from '../application/hooks/useContextMenu';
import { useQuickCreateMenu } from '../application/hooks/useQuickCreateMenu';
import { EditorToolbar } from '../presentation/components/toolbar/EditorToolbar';
import { QuickCreateMenu } from '../presentation/components/menu/QuickCreateMenu';
import { NodeContextMenu } from '../presentation/components/menu/NodeContextMenu';
import { BehaviorTreeNode as BehaviorTreeNodeComponent } from '../presentation/components/behavior-tree/nodes/BehaviorTreeNode';
import { getPortPosition as getPortPositionUtil } from '../presentation/utils/portUtils';
import { useExecutionController } from '../presentation/hooks/useExecutionController';
import { useNodeTracking } from '../presentation/hooks/useNodeTracking';
import { useEditorState } from '../presentation/hooks/useEditorState';
import { useEditorHandlers } from '../presentation/hooks/useEditorHandlers';
import { ICON_MAP, ROOT_NODE_TEMPLATE, DEFAULT_EDITOR_CONFIG } from '../presentation/config/editorConstants';
import '../styles/BehaviorTreeNode.css';
type BlackboardVariables = Record<string, BlackboardValue>;
interface BehaviorTreeEditorProps {
onNodeSelect?: (node: BehaviorTreeNode) => void;
onNodeCreate?: (template: NodeTemplate, position: { x: number; y: number }) => void;
blackboardVariables?: BlackboardVariables;
projectPath?: string | null;
showToolbar?: boolean;
}
export const BehaviorTreeEditor: React.FC<BehaviorTreeEditorProps> = ({
onNodeSelect,
onNodeCreate,
blackboardVariables = {},
projectPath = null,
showToolbar = true
}) => {
const { showToast } = useToast();
// 数据 store行为树数据
const {
nodes,
connections,
connectingFrom,
connectingFromProperty,
connectingToPos,
isBoxSelecting,
boxSelectStart,
boxSelectEnd,
setNodes,
setConnections,
setConnectingFrom,
setConnectingFromProperty,
setConnectingToPos,
clearConnecting,
setIsBoxSelecting,
setBoxSelectStart,
setBoxSelectEnd,
clearBoxSelect,
triggerForceUpdate,
sortChildrenByPosition,
setBlackboardVariables,
setInitialBlackboardVariables,
setIsExecuting,
initialBlackboardVariables,
isExecuting,
saveNodesDataSnapshot,
restoreNodesData,
nodeExecutionStatuses,
nodeExecutionOrders
} = useBehaviorTreeStore();
// UI store选中、拖拽、画布状态
const {
selectedNodeIds,
draggingNodeId,
dragStartPositions,
isDraggingNode,
canvasOffset,
canvasScale,
dragDelta,
setSelectedNodeIds,
startDragging,
stopDragging,
setIsDraggingNode,
resetView,
setDragDelta
} = useUIStore();
// 依赖注入 - 基础设施
const nodeFactory = useMemo(() => new NodeFactory(), []);
const validator = useMemo(() => new BehaviorTreeValidator(), []);
// 命令历史管理(创建 CommandManager
const { commandManager, canUndo, canRedo, undo, redo } = useCommandHistory();
// 应用层 hooks使用统一的 commandManager
const nodeOperations = useNodeOperations(nodeFactory, validator, commandManager);
const connectionOperations = useConnectionOperations(validator, commandManager);
// 右键菜单
const { contextMenu, setContextMenu, handleNodeContextMenu, handleCanvasContextMenu, closeContextMenu } = useContextMenu();
// 组件挂载和连线变化时强制更新,确保连线能正确渲染
useEffect(() => {
if (nodes.length > 0 || connections.length > 0) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
triggerForceUpdate();
});
});
}
}, [nodes.length, connections.length]);
// 点击其他地方关闭右键菜单
useEffect(() => {
const handleClick = () => {
if (contextMenu.visible) {
closeContextMenu();
}
};
if (contextMenu.visible) {
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}
}, [contextMenu.visible, closeContextMenu]);
const {
canvasRef,
stopExecutionRef,
executorRef,
selectedConnection,
setSelectedConnection
} = useEditorState();
const {
executionMode,
executionLogs,
executionSpeed,
tickCount,
handlePlay,
handlePause,
handleStop,
handleStep,
handleReset,
handleSpeedChange,
setExecutionLogs,
controller
} = useExecutionController({
rootNodeId: ROOT_NODE_ID,
projectPath,
blackboardVariables,
nodes,
connections,
initialBlackboardVariables,
onBlackboardUpdate: setBlackboardVariables,
onInitialBlackboardSave: setInitialBlackboardVariables,
onExecutingChange: setIsExecuting,
onSaveNodesDataSnapshot: saveNodesDataSnapshot,
onRestoreNodesData: restoreNodesData
});
executorRef.current = controller['executor'] || null;
const { uncommittedNodeIds } = useNodeTracking({
nodes,
executionMode
});
// 快速创建菜单
const {
quickCreateMenu,
setQuickCreateMenu,
handleQuickCreateNode
} = useQuickCreateMenu({
nodeOperations,
connectionOperations,
canvasRef,
canvasOffset,
canvasScale,
connectingFrom,
connectingFromProperty,
clearConnecting,
nodes,
setNodes,
connections,
executionMode,
onStop: () => stopExecutionRef.current?.(),
onNodeCreate,
showToast
});
// 节点拖拽
const {
handleNodeMouseDown,
handleNodeMouseMove,
handleNodeMouseUp
} = useNodeDrag({
canvasRef,
canvasOffset,
canvasScale,
nodes,
selectedNodeIds,
draggingNodeId,
dragStartPositions,
isDraggingNode,
dragDelta,
nodeOperations,
setSelectedNodeIds,
startDragging,
stopDragging,
setIsDraggingNode,
setDragDelta,
setIsBoxSelecting,
setBoxSelectStart,
setBoxSelectEnd,
sortChildrenByPosition
});
// 端口连接
const {
handlePortMouseDown,
handlePortMouseUp,
handleNodeMouseUpForConnection
} = usePortConnection({
canvasRef,
canvasOffset,
canvasScale,
nodes,
connections,
connectingFrom,
connectingFromProperty,
connectionOperations,
setConnectingFrom,
setConnectingFromProperty,
clearConnecting,
sortChildrenByPosition,
showToast
});
// 键盘快捷键
useKeyboardShortcuts({
selectedNodeIds,
selectedConnection,
connections,
nodeOperations,
connectionOperations,
setSelectedNodeIds,
setSelectedConnection
});
// 拖放处理
const {
isDragging,
handleDrop,
handleDragOver,
handleDragLeave,
handleDragEnter
} = useDropHandler({
canvasRef,
canvasOffset,
canvasScale,
nodeOperations,
onNodeCreate
});
// 画布鼠标事件
const {
handleCanvasMouseMove,
handleCanvasMouseUp,
handleCanvasMouseDown
} = useCanvasMouseEvents({
canvasRef,
canvasOffset,
canvasScale,
connectingFrom,
connectingToPos,
isBoxSelecting,
boxSelectStart,
boxSelectEnd,
nodes,
selectedNodeIds,
quickCreateMenu,
setConnectingToPos,
setIsBoxSelecting,
setBoxSelectStart,
setBoxSelectEnd,
setSelectedNodeIds,
setSelectedConnection,
setQuickCreateMenu,
clearConnecting,
clearBoxSelect,
showToast
});
const {
handleNodeClick,
handleResetView,
handleClearCanvas
} = useEditorHandlers({
isDraggingNode,
selectedNodeIds,
setSelectedNodeIds,
setNodes,
setConnections,
resetView,
triggerForceUpdate,
onNodeSelect,
rootNodeId: ROOT_NODE_ID,
rootNodeTemplate: ROOT_NODE_TEMPLATE
});
const getPortPosition = (nodeId: string, propertyName?: string, portType: 'input' | 'output' = 'output') =>
getPortPositionUtil(canvasRef, canvasOffset, canvasScale, nodes, nodeId, propertyName, portType);
stopExecutionRef.current = handleStop;
return (
<div style={{
width: '100%',
height: '100%',
flex: 1,
backgroundColor: '#1e1e1e',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
<style>{`
@keyframes pulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-50%, -50%) scale(1.02);
}
}
`}</style>
{/* 画布区域容器 */}
<div style={{
flex: 1,
position: 'relative',
minHeight: 0,
overflow: 'hidden'
}}>
{/* 画布 */}
<BehaviorTreeCanvas
ref={canvasRef}
config={DEFAULT_EDITOR_CONFIG}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onMouseDown={handleCanvasMouseDown}
onMouseMove={(e) => {
handleNodeMouseMove(e);
handleCanvasMouseMove(e);
}}
onMouseUp={(e) => {
handleNodeMouseUp();
handleCanvasMouseUp(e);
}}
onMouseLeave={(e) => {
handleNodeMouseUp();
handleCanvasMouseUp(e);
}}
onContextMenu={handleCanvasContextMenu}
>
{/* 连接线层 */}
<ConnectionLayer
connections={connections}
nodes={nodes}
selectedConnection={selectedConnection}
getPortPosition={getPortPosition}
onConnectionClick={(e, fromId, toId) => {
setSelectedConnection({ from: fromId, to: toId });
setSelectedNodeIds([]);
}}
/>
{/* 正在拖拽的连接线预览 */}
<svg style={{
position: 'absolute',
top: 0,
left: 0,
width: '10000px',
height: '10000px',
pointerEvents: 'none',
zIndex: 1,
overflow: 'visible'
}}>
{/* 正在拖拽的连接线 */}
{connectingFrom && connectingToPos && (() => {
const fromNode = nodes.find((n: BehaviorTreeNode) => n.id === connectingFrom);
if (!fromNode) return null;
let x1, y1;
let pathD: string;
const x2 = connectingToPos.x;
const y2 = connectingToPos.y;
// 判断是否是属性连接
const isPropertyConnection = !!connectingFromProperty;
const fromIsBlackboard = fromNode.data.nodeType === 'blackboard-variable';
const color = isPropertyConnection ? '#9c27b0' : '#0e639c';
if (isPropertyConnection && fromIsBlackboard) {
// 黑板变量节点的右侧输出引脚
x1 = fromNode.position.x + 75;
y1 = fromNode.position.y;
// 使用水平贝塞尔曲线
const controlX1 = x1 + (x2 - x1) * 0.5;
const controlX2 = x1 + (x2 - x1) * 0.5;
pathD = `M ${x1} ${y1} C ${controlX1} ${y1}, ${controlX2} ${y2}, ${x2} ${y2}`;
} else {
// 节点连接:从底部输出端口
x1 = fromNode.position.x;
y1 = fromNode.position.y + 30;
const controlY = y1 + (y2 - y1) * 0.5;
pathD = `M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`;
}
return (
<path
d={pathD}
stroke={color}
strokeWidth="2"
fill="none"
strokeDasharray="5,5"
style={{ pointerEvents: 'none' }}
/>
);
})()}
</svg>
{/* 框选矩形 */}
{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 width = maxX - minX;
const height = maxY - minY;
return (
<div style={{
position: 'absolute',
left: `${minX}px`,
top: `${minY}px`,
width: `${width}px`,
height: `${height}px`,
backgroundColor: 'rgba(14, 99, 156, 0.15)',
border: '2px solid rgba(14, 99, 156, 0.6)',
borderRadius: '4px',
pointerEvents: 'none',
zIndex: 999
}} />
);
})()}
{/* 节点列表 */}
{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 (
<BehaviorTreeNodeComponent
key={node.id}
node={node}
isSelected={isSelected}
isBeingDragged={isBeingDragged}
dragDelta={dragDelta}
uncommittedNodeIds={uncommittedNodeIds}
blackboardVariables={blackboardVariables}
initialBlackboardVariables={initialBlackboardVariables}
isExecuting={isExecuting}
executionStatus={executionStatus}
executionOrder={executionOrder}
connections={connections}
nodes={nodes}
executorRef={executorRef}
iconMap={ICON_MAP}
draggingNodeId={draggingNodeId}
onNodeClick={handleNodeClick}
onContextMenu={handleNodeContextMenu}
onNodeMouseDown={handleNodeMouseDown}
onNodeMouseUpForConnection={handleNodeMouseUpForConnection}
onPortMouseDown={handlePortMouseDown}
onPortMouseUp={handlePortMouseUp}
/>
);
})}
{/* 拖拽提示 - 相对于画布视口 */}
{isDragging && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
padding: '20px 40px',
backgroundColor: 'rgba(14, 99, 156, 0.2)',
border: '2px dashed #0e639c',
borderRadius: '8px',
color: '#0e639c',
fontSize: '16px',
fontWeight: 'bold',
pointerEvents: 'none',
zIndex: 1000
}}>
</div>
)}
{/* 空状态提示 - 相对于画布视口 */}
{nodes.length === 1 && !isDragging && (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
textAlign: 'center',
color: '#666',
fontSize: '14px',
pointerEvents: 'none'
}}>
<div style={{ fontSize: '48px', marginBottom: '20px' }}>👇</div>
<div style={{ marginBottom: '10px' }}> Root </div>
<div style={{ fontSize: '12px', color: '#555' }}>
Root
</div>
</div>
)}
</BehaviorTreeCanvas>
{/* 运行控制工具栏 */}
{showToolbar && (
<EditorToolbar
executionMode={executionMode}
canUndo={canUndo}
canRedo={canRedo}
onPlay={handlePlay}
onPause={handlePause}
onStop={handleStop}
onStep={handleStep}
onReset={handleReset}
onUndo={undo}
onRedo={redo}
onResetView={handleResetView}
onClearCanvas={handleClearCanvas}
/>
)}
{/* 快速创建菜单 */}
<QuickCreateMenu
visible={quickCreateMenu.visible}
position={quickCreateMenu.position}
searchText={quickCreateMenu.searchText}
selectedIndex={quickCreateMenu.selectedIndex}
mode={quickCreateMenu.mode}
iconMap={ICON_MAP}
onSearchChange={(text) => setQuickCreateMenu(prev => ({
...prev,
searchText: text
}))}
onIndexChange={(index) => setQuickCreateMenu(prev => ({
...prev,
selectedIndex: index
}))}
onNodeSelect={handleQuickCreateNode}
onClose={() => {
setQuickCreateMenu({
visible: false,
position: { x: 0, y: 0 },
searchText: '',
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
clearConnecting();
}}
/>
{/* 状态栏 */}
<div style={{
position: 'absolute',
bottom: '0',
left: '0',
right: '0',
padding: '8px 15px',
backgroundColor: 'rgba(45, 45, 45, 0.95)',
borderTop: '1px solid #333',
fontSize: '12px',
color: '#999',
display: 'flex',
justifyContent: 'space-between'
}}>
<div>: {nodes.length}</div>
<div style={{ display: 'flex', gap: '20px', alignItems: 'center' }}>
{executionMode === 'running' && (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<RotateCcw size={14} />
Tick: {tickCount}
</div>
)}
<div>{selectedNodeIds.length > 0 ? `已选择 ${selectedNodeIds.length} 个节点` : '未选择节点'}</div>
</div>
</div>
</div>
{/* 右键菜单 */}
<NodeContextMenu
visible={contextMenu.visible}
position={contextMenu.position}
nodeId={contextMenu.nodeId}
onReplaceNode={() => {
setQuickCreateMenu({
visible: true,
position: contextMenu.position,
searchText: '',
selectedIndex: 0,
mode: 'replace',
replaceNodeId: contextMenu.nodeId
});
setContextMenu({ ...contextMenu, visible: false });
}}
onDeleteNode={() => {
if (contextMenu.nodeId) {
nodeOperations.deleteNode(contextMenu.nodeId);
setContextMenu({ ...contextMenu, visible: false });
}
}}
onCreateNode={() => {
setQuickCreateMenu({
visible: true,
position: contextMenu.position,
searchText: '',
selectedIndex: 0,
mode: 'create',
replaceNodeId: null
});
setContextMenu({ ...contextMenu, visible: false });
}}
/>
</div>
);
};

View File

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

View File

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

View File

@@ -1,281 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { NodeTemplates, NodeTemplate } from '@esengine/behavior-tree';
import { Core } from '@esengine/ecs-framework';
import { EditorPluginManager, MessageHub } from '@esengine/editor-core';
import { NodeIcon } from './NodeIcon';
interface BehaviorTreeNodePaletteProps {
onNodeSelect?: (template: NodeTemplate) => void;
}
/**
* 获取节点类型对应的颜色
*/
const getTypeColor = (type: string): string => {
switch (type) {
case 'composite': return '#1976d2';
case 'action': return '#388e3c';
case 'condition': return '#d32f2f';
case 'decorator': return '#fb8c00';
case 'blackboard': return '#8e24aa';
default: return '#7b1fa2';
}
};
/**
* 行为树节点面板
*
* 显示所有可用的行为树节点模板,支持拖拽创建
*/
export const BehaviorTreeNodePalette: React.FC<BehaviorTreeNodePaletteProps> = ({
onNodeSelect
}) => {
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [allTemplates, setAllTemplates] = useState<NodeTemplate[]>([]);
// 获取所有节点模板(包括插件提供的)
const loadAllTemplates = () => {
console.log('[BehaviorTreeNodePalette] 开始加载节点模板');
try {
const pluginManager = Core.services.resolve(EditorPluginManager);
const allPlugins = pluginManager.getAllEditorPlugins();
console.log('[BehaviorTreeNodePalette] 找到插件数量:', allPlugins.length);
// 合并所有插件的节点模板
const templates: NodeTemplate[] = [];
for (const plugin of allPlugins) {
if (plugin.getNodeTemplates) {
console.log('[BehaviorTreeNodePalette] 从插件获取模板:', plugin.name);
const pluginTemplates = plugin.getNodeTemplates();
console.log('[BehaviorTreeNodePalette] 插件提供的模板数量:', pluginTemplates.length);
if (pluginTemplates.length > 0) {
console.log('[BehaviorTreeNodePalette] 第一个模板:', pluginTemplates[0].displayName);
}
templates.push(...pluginTemplates);
}
}
// 如果没有插件提供模板,回退到装饰器注册的模板
if (templates.length === 0) {
console.log('[BehaviorTreeNodePalette] 没有插件提供模板,使用默认模板');
templates.push(...NodeTemplates.getAllTemplates());
}
console.log('[BehaviorTreeNodePalette] 总共加载了', templates.length, '个模板');
setAllTemplates(templates);
} catch (error) {
console.error('[BehaviorTreeNodePalette] 加载模板失败:', error);
// 如果无法访问插件管理器,使用默认模板
setAllTemplates(NodeTemplates.getAllTemplates());
}
};
// 初始加载
useEffect(() => {
loadAllTemplates();
}, []);
// 监听语言变化事件
useEffect(() => {
try {
const messageHub = Core.services.resolve(MessageHub);
console.log('[BehaviorTreeNodePalette] 订阅 locale:changed 事件');
const unsubscribe = messageHub.subscribe('locale:changed', (data: any) => {
console.log('[BehaviorTreeNodePalette] 收到 locale:changed 事件:', data);
// 语言变化时重新加载模板
loadAllTemplates();
});
return () => {
console.log('[BehaviorTreeNodePalette] 取消订阅 locale:changed 事件');
unsubscribe();
};
} catch (error) {
console.error('[BehaviorTreeNodePalette] 订阅事件失败:', error);
// 如果无法访问 MessageHub忽略
}
}, []);
// 按类别分组(排除根节点类别)
const categories = useMemo(() =>
['all', ...new Set(allTemplates
.filter((t) => t.category !== '根节点')
.map((t) => t.category))]
, [allTemplates]);
const filteredTemplates = useMemo(() =>
(selectedCategory === 'all'
? allTemplates
: allTemplates.filter((t) => t.category === selectedCategory))
.filter((t) => t.category !== '根节点')
, [allTemplates, selectedCategory]);
const handleNodeClick = (template: NodeTemplate) => {
onNodeSelect?.(template);
};
const handleDragStart = (e: React.DragEvent, template: NodeTemplate) => {
const templateJson = JSON.stringify(template);
e.dataTransfer.setData('application/behavior-tree-node', templateJson);
e.dataTransfer.setData('text/plain', templateJson);
e.dataTransfer.effectAllowed = 'copy';
const dragImage = e.currentTarget as HTMLElement;
if (dragImage) {
e.dataTransfer.setDragImage(dragImage, 50, 25);
}
};
return (
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
backgroundColor: '#1e1e1e',
color: '#cccccc',
fontFamily: 'sans-serif'
}}>
<style>{`
.node-palette-list::-webkit-scrollbar {
width: 8px;
}
.node-palette-list::-webkit-scrollbar-track {
background: #1e1e1e;
}
.node-palette-list::-webkit-scrollbar-thumb {
background: #3c3c3c;
border-radius: 4px;
}
.node-palette-list::-webkit-scrollbar-thumb:hover {
background: #4c4c4c;
}
`}</style>
{/* 类别选择器 */}
<div style={{
padding: '10px',
borderBottom: '1px solid #333',
display: 'flex',
flexWrap: 'wrap',
gap: '5px'
}}>
{categories.map((category) => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
style={{
padding: '5px 10px',
backgroundColor: selectedCategory === category ? '#0e639c' : '#3c3c3c',
color: '#cccccc',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '12px'
}}
>
{category}
</button>
))}
</div>
{/* 节点列表 */}
<div className="node-palette-list" style={{
flex: 1,
overflowY: 'auto',
padding: '10px'
}}>
{filteredTemplates.map((template, index) => {
const className = template.className || '';
return (
<div
key={index}
draggable={true}
onDragStart={(e) => handleDragStart(e, template)}
onClick={() => handleNodeClick(template)}
style={{
padding: '10px',
marginBottom: '8px',
backgroundColor: '#2d2d2d',
borderLeft: `4px solid ${getTypeColor(template.type || '')}`,
borderRadius: '3px',
cursor: 'grab',
transition: 'all 0.2s',
userSelect: 'none',
WebkitUserSelect: 'none'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#3d3d3d';
e.currentTarget.style.transform = 'translateX(2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#2d2d2d';
e.currentTarget.style.transform = 'translateX(0)';
}}
onMouseDown={(e) => {
e.currentTarget.style.cursor = 'grabbing';
}}
onMouseUp={(e) => {
e.currentTarget.style.cursor = 'grab';
}}
>
<div style={{
display: 'flex',
alignItems: 'flex-start',
marginBottom: '5px',
pointerEvents: 'none',
gap: '8px'
}}>
{template.icon && (
<span style={{ display: 'flex', alignItems: 'center', paddingTop: '2px' }}>
<NodeIcon iconName={template.icon} size={16} />
</span>
)}
<div style={{ flex: 1 }}>
<div style={{ fontSize: '14px', fontWeight: '500', marginBottom: '2px' }}>
{template.displayName}
</div>
{className && (
<div style={{
color: '#666',
fontSize: '10px',
fontFamily: 'Consolas, Monaco, monospace',
opacity: 0.8
}}>
{className}
</div>
)}
</div>
</div>
<div style={{
fontSize: '12px',
color: '#999',
lineHeight: '1.4',
pointerEvents: 'none'
}}>
{template.description}
</div>
<div style={{
marginTop: '5px',
fontSize: '11px',
color: '#666',
pointerEvents: 'none'
}}>
{template.category}
</div>
</div>
);
})}
</div>
{/* 帮助提示 */}
<div style={{
padding: '10px',
borderTop: '1px solid #333',
fontSize: '11px',
color: '#666',
textAlign: 'center'
}}>
</div>
</div>
);
};

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
import { useState, useEffect } from 'react';
import { X, Cpu } from 'lucide-react';
import { ICompiler, CompileResult, CompilerContext } from '@esengine/editor-core';
import '../styles/CompileDialog.css';
interface CompileDialogProps<TOptions = unknown> {
isOpen: boolean;
onClose: () => void;
compiler: ICompiler<TOptions>;
context: CompilerContext;
initialOptions?: TOptions;
}
export function CompileDialog<TOptions = unknown>({
isOpen,
onClose,
compiler,
context,
initialOptions
}: CompileDialogProps<TOptions>) {
const [options, setOptions] = useState<TOptions>(initialOptions as TOptions);
const [isCompiling, setIsCompiling] = useState(false);
const [result, setResult] = useState<CompileResult | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
useEffect(() => {
if (isOpen && initialOptions) {
setOptions(initialOptions);
setResult(null);
setValidationError(null);
}
}, [isOpen, initialOptions]);
useEffect(() => {
if (compiler.validateOptions && options) {
const error = compiler.validateOptions(options);
setValidationError(error);
}
}, [options, compiler]);
if (!isOpen) return null;
const handleCompile = async () => {
if (validationError) {
return;
}
setIsCompiling(true);
setResult(null);
try {
const compileResult = await compiler.compile(options, context);
setResult(compileResult);
} catch (error) {
setResult({
success: false,
message: `编译失败: ${error}`,
errors: [String(error)]
});
} finally {
setIsCompiling(false);
}
};
return (
<div className="compile-dialog-overlay">
<div className="compile-dialog">
<div className="compile-dialog-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Cpu size={20} />
<h3>{compiler.name}</h3>
</div>
<button onClick={onClose} className="compile-dialog-close">
<X size={20} />
</button>
</div>
<div className="compile-dialog-content">
{compiler.description && (
<div className="compile-dialog-description">
{compiler.description}
</div>
)}
{compiler.createConfigUI && compiler.createConfigUI(setOptions, context)}
{validationError && (
<div className="compile-dialog-error">
{validationError}
</div>
)}
{result && (
<div className={`compile-dialog-result ${result.success ? 'success' : 'error'}`}>
<div className="compile-dialog-result-message">
{result.message}
</div>
{result.outputFiles && result.outputFiles.length > 0 && (
<div className="compile-dialog-output-files">
<div style={{ fontWeight: 600, marginBottom: '8px' }}>:</div>
{result.outputFiles.map((file, index) => (
<div key={index} className="compile-dialog-output-file">
{file}
</div>
))}
</div>
)}
{result.errors && result.errors.length > 0 && (
<div className="compile-dialog-errors">
<div style={{ fontWeight: 600, marginBottom: '8px' }}>:</div>
{result.errors.map((error, index) => (
<div key={index} className="compile-dialog-error-item">
{error}
</div>
))}
</div>
)}
</div>
)}
</div>
<div className="compile-dialog-footer">
<button
onClick={onClose}
className="compile-dialog-btn compile-dialog-btn-cancel"
disabled={isCompiling}
>
</button>
<button
onClick={handleCompile}
className="compile-dialog-btn compile-dialog-btn-primary"
disabled={isCompiling || !!validationError}
>
{isCompiling ? '编译中...' : '编译'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Core, IService, ServiceType } from '@esengine/ecs-framework';
import { CompilerRegistry, ICompiler, CompilerContext, CompileResult, IFileSystem, IDialog, FileEntry } from '@esengine/editor-core';
import { X, Play, Loader2 } from 'lucide-react';
import { open as tauriOpen, save as tauriSave, message as tauriMessage, confirm as tauriConfirm } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import '../styles/CompilerConfigDialog.css';
interface DirectoryEntry {
name: string;
path: string;
is_dir: boolean;
}
interface CompilerConfigDialogProps {
isOpen: boolean;
compilerId: string;
projectPath: string | null;
currentFileName?: string;
onClose: () => void;
onCompileComplete?: (result: CompileResult) => void;
}
export const CompilerConfigDialog: React.FC<CompilerConfigDialogProps> = ({
isOpen,
compilerId,
projectPath,
currentFileName,
onClose,
onCompileComplete
}) => {
const [compiler, setCompiler] = useState<ICompiler | null>(null);
const [options, setOptions] = useState<unknown>(null);
const [isCompiling, setIsCompiling] = useState(false);
const [compileResult, setCompileResult] = useState<CompileResult | null>(null);
const optionsRef = useRef<unknown>(null);
useEffect(() => {
if (isOpen && compilerId) {
try {
const registry = Core.services.resolve(CompilerRegistry);
console.log('[CompilerConfigDialog] Registry resolved:', registry);
console.log('[CompilerConfigDialog] Available compilers:', registry.getAll().map(c => c.id));
const comp = registry.get(compilerId);
console.log(`[CompilerConfigDialog] Looking for compiler: ${compilerId}, found:`, comp);
setCompiler(comp || null);
} catch (error) {
console.error('[CompilerConfigDialog] Failed to resolve CompilerRegistry:', error);
setCompiler(null);
}
}
}, [isOpen, compilerId]);
const handleOptionsChange = useCallback((newOptions: unknown) => {
optionsRef.current = newOptions;
setOptions(newOptions);
}, []);
const createFileSystem = (): IFileSystem => ({
readFile: async (path: string) => {
return await invoke<string>('read_file_content', { path });
},
writeFile: async (path: string, content: string) => {
await invoke('write_file_content', { path, content });
},
writeBinary: async (path: string, data: Uint8Array) => {
await invoke('write_binary_file', { filePath: path, content: Array.from(data) });
},
exists: async (path: string) => {
return await invoke<boolean>('path_exists', { path });
},
createDirectory: async (path: string) => {
await invoke('create_directory', { path });
},
listDirectory: async (path: string): Promise<FileEntry[]> => {
const entries = await invoke<DirectoryEntry[]>('list_directory', { path });
return entries.map(e => ({
name: e.name,
path: e.path,
isDirectory: e.is_dir
}));
},
deleteFile: async (path: string) => {
await invoke('delete_file', { path });
},
deleteDirectory: async (path: string) => {
await invoke('delete_folder', { path });
},
scanFiles: async (dir: string, pattern: string) => {
// Check if directory exists, create if not
const dirExists = await invoke<boolean>('path_exists', { path: dir });
if (!dirExists) {
await invoke('create_directory', { path: dir });
return []; // New directory has no files
}
const entries = await invoke<DirectoryEntry[]>('list_directory', { path: dir });
const ext = pattern.replace(/\*/g, '');
return entries
.filter(e => !e.is_dir && e.name.endsWith(ext))
.map(e => e.name.replace(ext, ''));
}
});
const createDialog = (): IDialog => ({
openDialog: async (opts) => {
const result = await tauriOpen({
directory: opts.directory,
multiple: opts.multiple,
title: opts.title,
defaultPath: opts.defaultPath
});
return result;
},
saveDialog: async (opts) => {
const result = await tauriSave({
title: opts.title,
defaultPath: opts.defaultPath,
filters: opts.filters
});
return result;
},
showMessage: async (title: string, message: string, type?: 'info' | 'warning' | 'error') => {
await tauriMessage(message, { title, kind: type || 'info' });
},
showConfirm: async (title: string, message: string) => {
return await tauriConfirm(message, { title });
}
});
const createContext = (): CompilerContext => ({
projectPath,
moduleContext: {
fileSystem: createFileSystem(),
dialog: createDialog()
},
getService: <T extends IService>(serviceClass: ServiceType<T>): T | undefined => {
try {
return Core.services.resolve(serviceClass);
} catch {
return undefined;
}
}
});
const handleCompile = async () => {
if (!compiler || !optionsRef.current) return;
setIsCompiling(true);
setCompileResult(null);
try {
const context = createContext();
const result = await compiler.compile(optionsRef.current, context);
setCompileResult(result);
onCompileComplete?.(result);
if (result.success) {
setTimeout(() => {
onClose();
}, 2000);
}
} catch (error) {
setCompileResult({
success: false,
message: `编译失败: ${error}`,
errors: [String(error)]
});
} finally {
setIsCompiling(false);
}
};
if (!isOpen) return null;
const context = createContext();
return (
<div className="compiler-dialog-overlay">
<div className="compiler-dialog">
<div className="compiler-dialog-header">
<h3>{compiler?.name || '编译器配置'}</h3>
<button className="close-button" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="compiler-dialog-body">
{compiler?.createConfigUI ? (
compiler.createConfigUI(handleOptionsChange, context)
) : (
<div className="no-config">
{compiler ? '该编译器没有配置界面' : '编译器未找到'}
</div>
)}
</div>
{compileResult && (
<div className={`compile-result ${compileResult.success ? 'success' : 'error'}`}>
<div className="result-message">{compileResult.message}</div>
{compileResult.outputFiles && compileResult.outputFiles.length > 0 && (
<div className="output-files">
{compileResult.outputFiles.length}
</div>
)}
{compileResult.errors && compileResult.errors.length > 0 && (
<div className="error-list">
{compileResult.errors.map((err, i) => (
<div key={i} className="error-item">{err}</div>
))}
</div>
)}
</div>
)}
<div className="compiler-dialog-footer">
<button
className="cancel-button"
onClick={onClose}
disabled={isCompiling}
>
</button>
<button
className="compile-button"
onClick={handleCompile}
disabled={isCompiling || !compiler || !options}
>
{isCompiling ? (
<>
<Loader2 size={16} className="spinning" />
...
</>
) : (
<>
<Play size={16} />
</>
)}
</button>
</div>
</div>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo, memo } from 'react';
import { LogService, LogEntry } from '@esengine/editor-core';
import { LogLevel } from '@esengine/ecs-framework';
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Maximize2, ChevronRight, ChevronDown, Wifi } from 'lucide-react';
import { Trash2, AlertCircle, Info, AlertTriangle, XCircle, Bug, Search, Wifi } from 'lucide-react';
import { JsonViewer } from './JsonViewer';
import '../styles/ConsolePanel.css';
@@ -9,114 +9,73 @@ interface ConsolePanelProps {
logService: LogService;
}
interface ParsedLogData {
isJSON: boolean;
jsonStr?: string;
extracted?: { prefix: string; json: string; suffix: string } | null;
const MAX_LOGS = 1000;
// 提取JSON检测和格式化逻辑
function tryParseJSON(message: string): { isJSON: boolean; parsed?: unknown } {
try {
const parsed: unknown = JSON.parse(message);
return { isJSON: true, parsed };
} catch {
return { isJSON: false };
}
}
const LogEntryItem = memo(({
log,
isExpanded,
onToggleExpand,
onOpenJsonViewer,
parsedData
}: {
// 格式化时间
function formatTime(date: Date): string {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
const ms = date.getMilliseconds().toString().padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${ms}`;
}
// 日志等级图标
function getLevelIcon(level: LogLevel) {
switch (level) {
case LogLevel.Debug:
return <Bug size={14} />;
case LogLevel.Info:
return <Info size={14} />;
case LogLevel.Warn:
return <AlertTriangle size={14} />;
case LogLevel.Error:
case LogLevel.Fatal:
return <XCircle size={14} />;
default:
return <AlertCircle size={14} />;
}
}
// 日志等级样式类
function getLevelClass(level: LogLevel): string {
switch (level) {
case LogLevel.Debug:
return 'log-entry-debug';
case LogLevel.Info:
return 'log-entry-info';
case LogLevel.Warn:
return 'log-entry-warn';
case LogLevel.Error:
case LogLevel.Fatal:
return 'log-entry-error';
default:
return '';
}
}
// 单个日志条目组件
const LogEntryItem = memo(({ log, onOpenJsonViewer }: {
log: LogEntry;
isExpanded: boolean;
onToggleExpand: (id: number) => void;
onOpenJsonViewer: (jsonStr: string) => void;
parsedData: ParsedLogData;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onOpenJsonViewer: (data: any) => void;
}) => {
const getLevelIcon = (level: LogLevel) => {
switch (level) {
case LogLevel.Debug:
return <Bug size={14} />;
case LogLevel.Info:
return <Info size={14} />;
case LogLevel.Warn:
return <AlertTriangle size={14} />;
case LogLevel.Error:
case LogLevel.Fatal:
return <XCircle size={14} />;
default:
return <AlertCircle size={14} />;
}
};
const getLevelClass = (level: LogLevel): string => {
switch (level) {
case LogLevel.Debug:
return 'log-entry-debug';
case LogLevel.Info:
return 'log-entry-info';
case LogLevel.Warn:
return 'log-entry-warn';
case LogLevel.Error:
case LogLevel.Fatal:
return 'log-entry-error';
default:
return '';
}
};
const formatTime = (date: Date): string => {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const seconds = date.getSeconds().toString().padStart(2, '0');
const ms = date.getMilliseconds().toString().padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${ms}`;
};
const formatMessage = (message: string, isExpanded: boolean, parsedData: ParsedLogData): JSX.Element => {
const MAX_PREVIEW_LENGTH = 200;
const { isJSON, jsonStr, extracted } = parsedData;
const shouldTruncate = message.length > MAX_PREVIEW_LENGTH && !isExpanded;
return (
<div className="log-message-container">
<div className="log-message-text">
{shouldTruncate ? (
<>
{extracted && extracted.prefix && <span>{extracted.prefix} </span>}
<span className="log-message-preview">
{message.substring(0, MAX_PREVIEW_LENGTH)}...
</span>
</>
) : (
<span>{message}</span>
)}
</div>
{isJSON && jsonStr && (
<button
className="log-open-json-btn"
onClick={(e) => {
e.stopPropagation();
onOpenJsonViewer(jsonStr);
}}
title="Open in JSON Viewer"
>
<Maximize2 size={12} />
</button>
)}
</div>
);
};
const shouldShowExpander = log.message.length > 200;
const { isJSON, parsed } = useMemo(() => tryParseJSON(log.message), [log.message]);
const shouldTruncate = log.message.length > 200;
const [isExpanded, setIsExpanded] = useState(false);
return (
<div
className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''} ${isExpanded ? 'log-entry-expanded' : ''}`}
>
{shouldShowExpander && (
<div
className="log-entry-expander"
onClick={() => onToggleExpand(log.id)}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</div>
)}
<div className={`log-entry ${getLevelClass(log.level)} ${log.source === 'remote' ? 'log-entry-remote' : ''}`}>
<div className="log-entry-icon">
{getLevelIcon(log.level)}
</div>
@@ -132,7 +91,45 @@ const LogEntryItem = memo(({
</div>
)}
<div className="log-entry-message">
{formatMessage(log.message, isExpanded, parsedData)}
<div className="log-message-container">
<div className="log-message-text">
{shouldTruncate && !isExpanded ? (
<>
<span className="log-message-preview">
{log.message.substring(0, 200)}...
</span>
<button
className="log-expand-btn"
onClick={() => setIsExpanded(true)}
>
Show more
</button>
</>
) : (
<>
<span>{log.message}</span>
{shouldTruncate && (
<button
className="log-expand-btn"
onClick={() => setIsExpanded(false)}
>
Show less
</button>
)}
</>
)}
</div>
{isJSON && parsed !== undefined && (
<button
className="log-open-json-btn"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onClick={() => onOpenJsonViewer(parsed as any)}
title="Open in JSON Viewer"
>
JSON
</button>
)}
</div>
</div>
</div>
);
@@ -140,10 +137,9 @@ const LogEntryItem = memo(({
LogEntryItem.displayName = 'LogEntryItem';
const MAX_LOGS = 1000;
export function ConsolePanel({ logService }: ConsolePanelProps) {
const [logs, setLogs] = useState<LogEntry[]>([]);
// 状态管理
const [logs, setLogs] = useState<LogEntry[]>(() => logService.getLogs().slice(-MAX_LOGS));
const [filter, setFilter] = useState('');
const [levelFilter, setLevelFilter] = useState<Set<LogLevel>>(new Set([
LogLevel.Debug,
@@ -154,37 +150,30 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
]));
const [showRemoteOnly, setShowRemoteOnly] = useState(false);
const [autoScroll, setAutoScroll] = useState(true);
const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [jsonViewerData, setJsonViewerData] = useState<any>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
// 订阅日志更新
useEffect(() => {
setLogs(logService.getLogs().slice(-MAX_LOGS));
const unsubscribe = logService.subscribe((entry) => {
setLogs((prev) => {
const newLogs = [...prev, entry];
if (newLogs.length > MAX_LOGS) {
return newLogs.slice(-MAX_LOGS);
}
return newLogs;
return newLogs.length > MAX_LOGS ? newLogs.slice(-MAX_LOGS) : newLogs;
});
});
return unsubscribe;
}, [logService]);
// 自动滚动
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs, autoScroll]);
const handleClear = () => {
logService.clear();
setLogs([]);
};
// 处理滚动
const handleScroll = () => {
if (logContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
@@ -193,6 +182,13 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
}
};
// 清空日志
const handleClear = () => {
logService.clear();
setLogs([]);
};
// 切换等级过滤
const toggleLevelFilter = (level: LogLevel) => {
const newFilter = new Set(levelFilter);
if (newFilter.has(level)) {
@@ -203,129 +199,7 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
setLevelFilter(newFilter);
};
// 使用ref保存缓存避免每次都重新计算
const parsedLogsCacheRef = useRef<Map<number, ParsedLogData>>(new Map());
const extractJSON = useMemo(() => {
return (message: string): { prefix: string; json: string; suffix: string } | null => {
// 快速路径:如果消息太短,直接返回
if (message.length < 2) return null;
const jsonStartChars = ['{', '['];
let startIndex = -1;
for (const char of jsonStartChars) {
const index = message.indexOf(char);
if (index !== -1 && (startIndex === -1 || index < startIndex)) {
startIndex = index;
}
}
if (startIndex === -1) return null;
// 使用栈匹配算法更高效地找到JSON边界
const startChar = message[startIndex];
const endChar = startChar === '{' ? '}' : ']';
let depth = 0;
let inString = false;
let escape = false;
for (let i = startIndex; i < message.length; i++) {
const char = message[i];
if (escape) {
escape = false;
continue;
}
if (char === '\\') {
escape = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (char === startChar) {
depth++;
} else if (char === endChar) {
depth--;
if (depth === 0) {
// 找到匹配的结束符
const possibleJson = message.substring(startIndex, i + 1);
try {
JSON.parse(possibleJson);
return {
prefix: message.substring(0, startIndex).trim(),
json: possibleJson,
suffix: message.substring(i + 1).trim()
};
} catch {
return null;
}
}
}
}
return null;
};
}, []);
const parsedLogsCache = useMemo(() => {
const cache = parsedLogsCacheRef.current;
// 只处理新增的日志
for (const log of logs) {
// 如果已经缓存过,跳过
if (cache.has(log.id)) continue;
try {
JSON.parse(log.message);
cache.set(log.id, {
isJSON: true,
jsonStr: log.message,
extracted: null
});
} catch {
const extracted = extractJSON(log.message);
if (extracted) {
try {
JSON.parse(extracted.json);
cache.set(log.id, {
isJSON: true,
jsonStr: extracted.json,
extracted
});
} catch {
cache.set(log.id, {
isJSON: false,
extracted
});
}
} else {
cache.set(log.id, {
isJSON: false,
extracted: null
});
}
}
}
// 清理不再需要的缓存(日志被删除)
const logIds = new Set(logs.map((log) => log.id));
for (const cachedId of cache.keys()) {
if (!logIds.has(cachedId)) {
cache.delete(cachedId);
}
}
return cache;
}, [logs, extractJSON]);
// 过滤日志
const filteredLogs = useMemo(() => {
return logs.filter((log) => {
if (!levelFilter.has(log.level)) return false;
@@ -337,25 +211,7 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
});
}, [logs, levelFilter, showRemoteOnly, filter]);
const toggleLogExpand = (logId: number) => {
const newExpanded = new Set(expandedLogs);
if (newExpanded.has(logId)) {
newExpanded.delete(logId);
} else {
newExpanded.add(logId);
}
setExpandedLogs(newExpanded);
};
const openJsonViewer = (jsonStr: string) => {
try {
const parsed = JSON.parse(jsonStr);
setJsonViewerData(parsed);
} catch {
console.error('Failed to parse JSON:', jsonStr);
}
};
// 统计各等级日志数量
const levelCounts = useMemo(() => ({
[LogLevel.Debug]: logs.filter((l) => l.level === LogLevel.Debug).length,
[LogLevel.Info]: logs.filter((l) => l.level === LogLevel.Info).length,
@@ -446,10 +302,7 @@ export function ConsolePanel({ logService }: ConsolePanelProps) {
<LogEntryItem
key={log.id}
log={log}
isExpanded={expandedLogs.has(log.id)}
onToggleExpand={toggleLogExpand}
onOpenJsonViewer={openJsonViewer}
parsedData={parsedLogsCache.get(log.id) || { isJSON: false }}
onOpenJsonViewer={setJsonViewerData}
/>
))
)}

View File

@@ -30,6 +30,7 @@ interface FileTreeProps {
export interface FileTreeHandle {
collapseAll: () => void;
refresh: () => void;
}
export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, onSelectFile, selectedPath, messageHub, searchQuery, showFiles = true }, ref) => {
@@ -69,7 +70,8 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
};
useImperativeHandle(ref, () => ({
collapseAll
collapseAll,
refresh: refreshTree
}));
useEffect(() => {

View File

@@ -1,149 +1,10 @@
import { useCallback, ReactNode, useRef, useEffect, useState } from 'react';
import { Layout, Model, TabNode, IJsonModel, Actions, IJsonTabSetNode, IJsonRowNode, Action, IJsonTabNode, DockLocation } from 'flexlayout-react';
import { useCallback, useRef, useEffect, useState } from 'react';
import { Layout, Model, TabNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react';
import 'flexlayout-react/style/light.css';
import '../styles/FlexLayoutDock.css';
import { LayoutMerger, LayoutBuilder, FlexDockPanel } from '../shared/layout';
/**
* 合并保存的布局和新的默认布局
* 保留用户的布局调整(大小、位置等),同时添加新面板并移除已关闭的面板
*/
function mergeLayouts(savedLayout: IJsonModel, defaultLayout: IJsonModel, currentPanels: FlexDockPanel[]): IJsonModel {
// 获取当前所有面板ID
const currentPanelIds = new Set(currentPanels.map(p => p.id));
// 收集保存布局中存在的面板ID
const savedPanelIds = new Set<string>();
const collectPanelIds = (node: any) => {
if (node.type === 'tab' && node.id) {
savedPanelIds.add(node.id);
}
if (node.children) {
node.children.forEach((child: any) => collectPanelIds(child));
}
};
collectPanelIds(savedLayout.layout);
// 同时收集borders中的面板ID
if (savedLayout.borders) {
savedLayout.borders.forEach((border: any) => {
if (border.children) {
collectPanelIds({ children: border.children });
}
});
}
// 找出新增的面板和已移除的面板
const newPanelIds = Array.from(currentPanelIds).filter(id => !savedPanelIds.has(id));
const removedPanelIds = Array.from(savedPanelIds).filter(id => !currentPanelIds.has(id));
// 克隆保存的布局
const mergedLayout = JSON.parse(JSON.stringify(savedLayout));
// 确保borders为空不保留最小化状态
if (mergedLayout.borders) {
mergedLayout.borders = mergedLayout.borders.map((border: any) => ({
...border,
children: []
}));
}
// 第一步:移除已关闭的面板
if (removedPanelIds.length > 0) {
const removePanels = (node: any): boolean => {
if (!node.children) return false;
// 过滤掉已移除的tab
if (node.type === 'tabset' || node.type === 'row') {
const originalLength = node.children.length;
node.children = node.children.filter((child: any) => {
if (child.type === 'tab') {
return !removedPanelIds.includes(child.id);
}
return true;
});
// 如果有tab被移除调整selected索引
if (node.type === 'tabset' && node.children.length < originalLength) {
if (node.selected >= node.children.length) {
node.selected = Math.max(0, node.children.length - 1);
}
}
// 递归处理子节点
node.children.forEach((child: any) => removePanels(child));
return node.children.length < originalLength;
}
return false;
};
removePanels(mergedLayout.layout);
}
// 第二步:如果没有新面板,直接返回清理后的布局
if (newPanelIds.length === 0) {
return mergedLayout;
}
// 第三步:在默认布局中找到新面板的配置
const newPanelTabs: IJsonTabNode[] = [];
const findNewPanels = (node: any) => {
if (node.type === 'tab' && node.id && newPanelIds.includes(node.id)) {
newPanelTabs.push(node);
}
if (node.children) {
node.children.forEach((child: any) => findNewPanels(child));
}
};
findNewPanels(defaultLayout.layout);
// 第四步将新面板添加到中心区域的第一个tabset
const addNewPanelsToCenter = (node: any): boolean => {
if (node.type === 'tabset') {
// 检查是否是中心区域的tabset通过检查是否包含非hierarchy/asset/inspector/console面板
const hasNonSidePanel = node.children?.some((child: any) => {
const id = child.id || '';
return !id.includes('hierarchy') &&
!id.includes('asset') &&
!id.includes('inspector') &&
!id.includes('console');
});
if (hasNonSidePanel && node.children) {
// 添加新面板到这个tabset
node.children.push(...newPanelTabs);
// 选中最后添加的面板
node.selected = node.children.length - 1;
return true;
}
}
if (node.children) {
for (const child of node.children) {
if (addNewPanelsToCenter(child)) {
return true;
}
}
}
return false;
};
// 尝试添加新面板到中心区域
if (!addNewPanelsToCenter(mergedLayout.layout)) {
// 如果没有找到合适的tabset使用默认布局
return defaultLayout;
}
return mergedLayout;
}
export interface FlexDockPanel {
id: string;
title: string;
content: ReactNode;
closable?: boolean;
}
export type { FlexDockPanel };
interface FlexLayoutDockContainerProps {
panels: FlexDockPanel[];
@@ -158,170 +19,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
const previousPanelTitlesRef = useRef<Map<string, string>>(new Map());
const createDefaultLayout = useCallback((): IJsonModel => {
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'));
const centerPanels = panels.filter((p) =>
!hierarchyPanels.includes(p) && !assetPanels.includes(p) && !rightPanels.includes(p) && !bottomPanels.includes(p)
);
// Build center column children
const centerColumnChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
if (centerPanels.length > 0) {
// 找到要激活的tab的索引
let activeTabIndex = 0;
if (activePanelId) {
const index = centerPanels.findIndex((p) => p.id === activePanelId);
if (index !== -1) {
activeTabIndex = index;
}
}
centerColumnChildren.push({
type: 'tabset',
weight: 70,
selected: activeTabIndex,
enableMaximize: true,
children: centerPanels.map((p) => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false
}))
});
}
if (bottomPanels.length > 0) {
centerColumnChildren.push({
type: 'tabset',
weight: 30,
enableMaximize: true,
children: bottomPanels.map((p) => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false
}))
});
}
// Build main row children
const mainRowChildren: (IJsonTabSetNode | IJsonRowNode)[] = [];
// 左侧列:场景层级和资产面板垂直排列(五五分)
if (hierarchyPanels.length > 0 || assetPanels.length > 0) {
const leftColumnChildren: IJsonTabSetNode[] = [];
if (hierarchyPanels.length > 0) {
leftColumnChildren.push({
type: 'tabset',
weight: 50,
enableMaximize: true,
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,
enableMaximize: true,
children: assetPanels.map((p) => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false
}))
});
}
mainRowChildren.push({
type: 'row',
weight: 20,
children: leftColumnChildren
});
}
if (centerColumnChildren.length > 0) {
if (centerColumnChildren.length === 1) {
const centerChild = centerColumnChildren[0];
if (centerChild && centerChild.type === 'tabset') {
mainRowChildren.push({
type: 'tabset',
weight: 60,
enableMaximize: true,
children: centerChild.children
} as IJsonTabSetNode);
} else if (centerChild) {
mainRowChildren.push({
type: 'row',
weight: 60,
children: centerChild.children
} as IJsonRowNode);
}
} else {
mainRowChildren.push({
type: 'row',
weight: 60,
children: centerColumnChildren
});
}
}
if (rightPanels.length > 0) {
mainRowChildren.push({
type: 'tabset',
weight: 20,
enableMaximize: true,
children: rightPanels.map((p) => ({
type: 'tab',
name: p.title,
id: p.id,
component: p.id,
enableClose: p.closable !== false
}))
});
}
return {
global: {
tabEnableClose: true,
tabEnableRename: false,
tabSetEnableMaximize: true,
tabSetEnableDrop: true,
tabSetEnableDrag: true,
tabSetEnableDivide: true,
borderEnableDrop: true,
borderAutoSelectTabWhenOpen: true,
borderAutoSelectTabWhenClosed: true
},
borders: [
{
type: 'border',
location: 'bottom',
size: 200,
children: []
},
{
type: 'border',
location: 'right',
size: 300,
children: []
}
],
layout: {
type: 'row',
weight: 100,
children: mainRowChildren
}
};
return LayoutBuilder.createDefaultLayout(panels, activePanelId);
}, [panels, activePanelId]);
const [model, setModel] = useState<Model>(() => {
@@ -440,7 +138,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId }:
if (previousLayoutJsonRef.current && previousIds) {
try {
const savedLayout = JSON.parse(previousLayoutJsonRef.current);
const mergedLayout = mergeLayouts(savedLayout, defaultLayout, panels);
const mergedLayout = LayoutMerger.merge(savedLayout, defaultLayout, panels);
const newModel = Model.fromJson(mergedLayout);
setModel(newModel);
return;

View File

@@ -0,0 +1,263 @@
import { useState } from 'react';
import { Github, AlertCircle, CheckCircle, Loader, ExternalLink } from 'lucide-react';
import { GitHubService } from '../services/GitHubService';
import { open } from '@tauri-apps/plugin-shell';
import '../styles/GitHubAuth.css';
interface GitHubAuthProps {
githubService: GitHubService;
onSuccess: () => void;
locale: string;
}
export function GitHubAuth({ githubService, onSuccess, locale }: GitHubAuthProps) {
const [useOAuth, setUseOAuth] = useState(true);
const [githubToken, setGithubToken] = useState('');
const [userCode, setUserCode] = useState('');
const [verificationUri, setVerificationUri] = useState('');
const [authStatus, setAuthStatus] = useState<'idle' | 'pending' | 'authorized' | 'error'>('idle');
const [error, setError] = useState('');
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
githubLogin: 'GitHub 登录',
oauthLogin: 'OAuth 登录(推荐)',
tokenLogin: 'Token 登录',
oauthStep1: '1. 点击"开始授权"按钮',
oauthStep2: '2. 在浏览器中打开 GitHub 授权页面',
oauthStep3: '3. 输入下方显示的代码并授权',
startAuth: '开始授权',
authorizing: '等待授权中...',
authorized: '授权成功!',
authFailed: '授权失败',
userCode: '授权码',
copyCode: '复制代码',
openBrowser: '打开浏览器',
tokenLabel: 'GitHub Personal Access Token',
tokenPlaceholder: '粘贴你的 GitHub Token',
tokenHint: '需要 repo 和 workflow 权限',
createToken: '创建 Token',
login: '登录',
back: '返回'
},
en: {
githubLogin: 'GitHub Login',
oauthLogin: 'OAuth Login (Recommended)',
tokenLogin: 'Token Login',
oauthStep1: '1. Click "Start Authorization"',
oauthStep2: '2. Open GitHub authorization page in browser',
oauthStep3: '3. Enter the code shown below and authorize',
startAuth: 'Start Authorization',
authorizing: 'Waiting for authorization...',
authorized: 'Authorized!',
authFailed: 'Authorization failed',
userCode: 'Authorization Code',
copyCode: 'Copy Code',
openBrowser: 'Open Browser',
tokenLabel: 'GitHub Personal Access Token',
tokenPlaceholder: 'Paste your GitHub Token',
tokenHint: 'Requires repo and workflow permissions',
createToken: 'Create Token',
login: 'Login',
back: 'Back'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
const handleOAuthLogin = async () => {
setAuthStatus('pending');
setError('');
try {
console.log('[GitHubAuth] Starting OAuth login...');
const deviceCodeResp = await githubService.requestDeviceCode();
console.log('[GitHubAuth] Device code received:', deviceCodeResp.user_code);
setUserCode(deviceCodeResp.user_code);
setVerificationUri(deviceCodeResp.verification_uri);
console.log('[GitHubAuth] Opening browser...');
await open(deviceCodeResp.verification_uri);
console.log('[GitHubAuth] Starting authentication polling...');
await githubService.authenticateWithDeviceFlow(
deviceCodeResp.device_code,
deviceCodeResp.interval,
(status) => {
console.log('[GitHubAuth] Auth status changed:', status);
setAuthStatus(status === 'pending' ? 'pending' : status === 'authorized' ? 'authorized' : 'error');
}
);
console.log('[GitHubAuth] Authorization successful!');
setAuthStatus('authorized');
setTimeout(() => {
onSuccess();
}, 1000);
} catch (err) {
console.error('[GitHubAuth] OAuth failed:', err);
setAuthStatus('error');
const errorMessage = err instanceof Error ? err.message : 'OAuth authorization failed';
const fullError = err instanceof Error && err.stack ? `${errorMessage}\n\nDetails: ${err.stack}` : errorMessage;
setError(fullError);
}
};
const handleTokenAuth = async () => {
if (!githubToken.trim()) {
setError(locale === 'zh' ? '请输入 Token' : 'Please enter a token');
return;
}
try {
await githubService.authenticate(githubToken);
setError('');
onSuccess();
} catch (err) {
console.error('[GitHubAuth] Token auth failed:', err);
setError(locale === 'zh' ? '认证失败,请检查你的 Token' : 'Authentication failed. Please check your token.');
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const openCreateTokenPage = async () => {
await githubService.openAuthorizationPage();
};
return (
<div className="github-auth">
<Github size={48} style={{ color: '#0366d6' }} />
<p>{t('githubLogin')}</p>
<div className="auth-tabs">
<button
className={`auth-tab ${useOAuth ? 'active' : ''}`}
onClick={() => setUseOAuth(true)}
>
{t('oauthLogin')}
</button>
<button
className={`auth-tab ${!useOAuth ? 'active' : ''}`}
onClick={() => setUseOAuth(false)}
>
{t('tokenLogin')}
</button>
</div>
{useOAuth ? (
<div className="oauth-auth">
{authStatus === 'idle' && (
<>
<div className="oauth-instructions">
<p>{t('oauthStep1')}</p>
<p>{t('oauthStep2')}</p>
<p>{t('oauthStep3')}</p>
</div>
<button className="btn-primary" onClick={handleOAuthLogin}>
<Github size={16} />
{t('startAuth')}
</button>
</>
)}
{authStatus === 'pending' && (
<div className="oauth-pending">
<Loader size={48} className="spinning" style={{ color: '#0366d6' }} />
<h4>{t('authorizing')}</h4>
{userCode && (
<div className="user-code-display">
<label>{t('userCode')}</label>
<div className="code-box">
<span className="code-text">{userCode}</span>
<button
className="btn-copy"
onClick={() => copyToClipboard(userCode)}
title={t('copyCode')}
>
📋
</button>
</div>
<button
className="btn-link"
onClick={() => open(verificationUri)}
>
<ExternalLink size={14} />
{t('openBrowser')}
</button>
</div>
)}
</div>
)}
{authStatus === 'authorized' && (
<div className="oauth-success">
<CheckCircle size={48} style={{ color: '#34c759' }} />
<h4>{t('authorized')}</h4>
</div>
)}
{authStatus === 'error' && (
<div className="oauth-error">
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
<h4>{t('authFailed')}</h4>
{error && (
<div className="error-details">
<pre>{error}</pre>
</div>
)}
<button className="btn-secondary" onClick={() => setAuthStatus('idle')}>
{t('back')}
</button>
</div>
)}
</div>
) : (
<div className="token-auth">
<div className="form-group">
<label>{t('tokenLabel')}</label>
<input
type="password"
value={githubToken}
onChange={(e) => setGithubToken(e.target.value)}
placeholder={t('tokenPlaceholder')}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleTokenAuth();
}
}}
/>
<small>{t('tokenHint')}</small>
</div>
<button className="btn-link" onClick={openCreateTokenPage}>
<ExternalLink size={14} />
{t('createToken')}
</button>
<button className="btn-primary" onClick={handleTokenAuth}>
{t('login')}
</button>
</div>
)}
{error && !useOAuth && (
<div className="error-message">
<AlertCircle size={16} />
{error}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { X } from 'lucide-react';
import { GitHubService } from '../services/GitHubService';
import { GitHubAuth } from './GitHubAuth';
import '../styles/GitHubLoginDialog.css';
interface GitHubLoginDialogProps {
githubService: GitHubService;
onClose: () => void;
locale: string;
}
export function GitHubLoginDialog({ githubService, onClose, locale }: GitHubLoginDialogProps) {
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
title: 'GitHub 登录'
},
en: {
title: 'GitHub Login'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
return (
<div className="github-login-overlay" onClick={onClose}>
<div className="github-login-dialog" onClick={(e) => e.stopPropagation()}>
<div className="github-login-header">
<h2>{t('title')}</h2>
<button className="github-login-close" onClick={onClose}>
<X size={20} />
</button>
</div>
<div className="github-login-content">
<GitHubAuth
githubService={githubService}
onSuccess={onClose}
locale={locale}
/>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ interface MenuBarProps {
onToggleDevtools?: () => void;
onOpenAbout?: () => void;
onCreatePlugin?: () => void;
onReloadPlugins?: () => void;
}
export function MenuBar({
@@ -53,7 +54,8 @@ export function MenuBar({
onOpenSettings,
onToggleDevtools,
onOpenAbout,
onCreatePlugin
onCreatePlugin,
onReloadPlugins
}: MenuBarProps) {
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
@@ -147,6 +149,7 @@ export function MenuBar({
pluginManager: 'Plugin Manager',
tools: 'Tools',
createPlugin: 'Create Plugin',
reloadPlugins: 'Reload Plugins',
portManager: 'Port Manager',
settings: 'Settings',
help: 'Help',
@@ -181,6 +184,7 @@ export function MenuBar({
pluginManager: '插件管理器',
tools: '工具',
createPlugin: '创建插件',
reloadPlugins: '重新加载插件',
portManager: '端口管理器',
settings: '设置',
help: '帮助',
@@ -231,6 +235,7 @@ export function MenuBar({
],
tools: [
{ label: t('createPlugin'), onClick: onCreatePlugin },
{ label: t('reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins },
{ separator: true },
{ label: t('portManager'), onClick: onOpenPortManager },
{ separator: true },

View File

@@ -1,15 +1,32 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { EditorPluginManager, IEditorPluginMetadata, EditorPluginCategory } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import { Package, CheckCircle, XCircle, Search, Grid, List, ChevronDown, ChevronRight, X, RefreshCw } from 'lucide-react';
import {
Package,
CheckCircle,
XCircle,
Search,
Grid,
List,
ChevronDown,
ChevronRight,
X,
RefreshCw,
ShoppingCart
} from 'lucide-react';
import { PluginMarketPanel } from './PluginMarketPanel';
import { PluginMarketService } from '../services/PluginMarketService';
import { GitHubService } from '../services/GitHubService';
import '../styles/PluginManagerWindow.css';
interface PluginManagerWindowProps {
pluginManager: EditorPluginManager;
githubService: GitHubService;
onClose: () => void;
onRefresh?: () => Promise<void>;
onOpen?: () => void;
locale: string;
projectPath: string | null;
}
const categoryIcons: Record<EditorPluginCategory, string> = {
@@ -20,7 +37,7 @@ const categoryIcons: Record<EditorPluginCategory, string> = {
[EditorPluginCategory.ImportExport]: 'Package'
};
export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen, locale }: PluginManagerWindowProps) {
export function PluginManagerWindow({ pluginManager, githubService, onClose, onRefresh, onOpen, locale, projectPath }: PluginManagerWindowProps) {
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
@@ -43,7 +60,9 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
categoryWindows: '窗口',
categoryInspectors: '检查器',
categorySystem: '系统',
categoryImportExport: '导入/导出'
categoryImportExport: '导入/导出',
tabInstalled: '已安装',
tabMarketplace: '插件市场'
},
en: {
title: 'Plugin Manager',
@@ -65,7 +84,9 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
categoryWindows: 'Windows',
categoryInspectors: 'Inspectors',
categorySystem: 'System',
categoryImportExport: 'Import/Export'
categoryImportExport: 'Import/Export',
tabInstalled: 'Installed',
tabMarketplace: 'Marketplace'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
@@ -81,6 +102,7 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
};
return t(categoryKeys[category]);
};
const [activeTab, setActiveTab] = useState<'installed' | 'marketplace'>('installed');
const [plugins, setPlugins] = useState<IEditorPluginMetadata[]>([]);
const [filter, setFilter] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
@@ -89,6 +111,13 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
);
const [isRefreshing, setIsRefreshing] = useState(false);
const marketService = useMemo(() => new PluginMarketService(pluginManager), [pluginManager]);
// 设置项目路径到 marketService
useEffect(() => {
marketService.setProjectPath(projectPath);
}, [projectPath, marketService]);
const updatePluginList = () => {
const allPlugins = pluginManager.getAllPluginMetadata();
setPlugins(allPlugins);
@@ -154,13 +183,16 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
);
});
const pluginsByCategory = filteredPlugins.reduce((acc, plugin) => {
if (!acc[plugin.category]) {
acc[plugin.category] = [];
}
acc[plugin.category].push(plugin);
return acc;
}, {} as Record<EditorPluginCategory, IEditorPluginMetadata[]>);
const pluginsByCategory = filteredPlugins.reduce(
(acc, plugin) => {
if (!acc[plugin.category]) {
acc[plugin.category] = [];
}
acc[plugin.category].push(plugin);
return acc;
},
{} as Record<EditorPluginCategory, IEditorPluginMetadata[]>
);
const enabledCount = plugins.filter((p) => p.enabled).length;
const disabledCount = plugins.filter((p) => !p.enabled).length;
@@ -185,9 +217,7 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
{plugin.enabled ? <CheckCircle size={18} /> : <XCircle size={18} />}
</button>
</div>
{plugin.description && (
<div className="plugin-card-description">{plugin.description}</div>
)}
{plugin.description && <div className="plugin-card-description">{plugin.description}</div>}
<div className="plugin-card-footer">
<span className="plugin-card-category">
{(() => {
@@ -218,9 +248,7 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
{plugin.displayName}
<span className="plugin-list-version">v{plugin.version}</span>
</div>
{plugin.description && (
<div className="plugin-list-description">{plugin.description}</div>
)}
{plugin.description && <div className="plugin-list-description">{plugin.description}</div>}
</div>
<div className="plugin-list-status">
{plugin.enabled ? (
@@ -253,118 +281,157 @@ export function PluginManagerWindow({ pluginManager, onClose, onRefresh, onOpen,
</button>
</div>
<div className="plugin-toolbar">
<div className="plugin-toolbar-left">
<div className="plugin-search">
<Search size={14} />
<input
type="text"
placeholder={t('searchPlaceholder')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
</div>
<div className="plugin-toolbar-right">
<div className="plugin-stats">
<span className="stat-item enabled">
<CheckCircle size={14} />
{enabledCount} {t('enabled')}
</span>
<span className="stat-item disabled">
<XCircle size={14} />
{disabledCount} {t('disabled')}
</span>
</div>
{onRefresh && (
<button
className="plugin-refresh-btn"
onClick={handleRefresh}
disabled={isRefreshing}
title={t('refreshPluginList')}
style={{
padding: '6px 12px',
display: 'flex',
alignItems: 'center',
gap: '6px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: isRefreshing ? 'not-allowed' : 'pointer',
fontSize: '12px',
opacity: isRefreshing ? 0.6 : 1
}}
>
<RefreshCw size={14} className={isRefreshing ? 'spinning' : ''} />
{t('refresh')}
</button>
)}
<div className="plugin-view-mode">
<button
className={viewMode === 'list' ? 'active' : ''}
onClick={() => setViewMode('list')}
title={t('listView')}
>
<List size={14} />
</button>
<button
className={viewMode === 'grid' ? 'active' : ''}
onClick={() => setViewMode('grid')}
title={t('gridView')}
>
<Grid size={14} />
</button>
</div>
</div>
<div className="plugin-manager-tabs">
<button
className={`plugin-manager-tab ${activeTab === 'installed' ? 'active' : ''}`}
onClick={() => setActiveTab('installed')}
>
<Package size={16} />
{t('tabInstalled')}
</button>
<button
className={`plugin-manager-tab ${activeTab === 'marketplace' ? 'active' : ''}`}
onClick={() => setActiveTab('marketplace')}
>
<ShoppingCart size={16} />
{t('tabMarketplace')}
</button>
</div>
<div className="plugin-content">
{plugins.length === 0 ? (
<div className="plugin-empty">
<Package size={48} />
<p>{t('noPlugins')}</p>
{activeTab === 'installed' && (
<>
<div className="plugin-toolbar">
<div className="plugin-toolbar-left">
<div className="plugin-search">
<Search size={14} />
<input
type="text"
placeholder={t('searchPlaceholder')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
</div>
<div className="plugin-toolbar-right">
<div className="plugin-stats">
<span className="stat-item enabled">
<CheckCircle size={14} />
{enabledCount} {t('enabled')}
</span>
<span className="stat-item disabled">
<XCircle size={14} />
{disabledCount} {t('disabled')}
</span>
</div>
{onRefresh && (
<button
className="plugin-refresh-btn"
onClick={handleRefresh}
disabled={isRefreshing}
title={t('refreshPluginList')}
style={{
padding: '6px 12px',
display: 'flex',
alignItems: 'center',
gap: '6px',
backgroundColor: '#0e639c',
border: 'none',
borderRadius: '4px',
color: '#fff',
cursor: isRefreshing ? 'not-allowed' : 'pointer',
fontSize: '12px',
opacity: isRefreshing ? 0.6 : 1
}}
>
<RefreshCw size={14} className={isRefreshing ? 'spinning' : ''} />
{t('refresh')}
</button>
)}
<div className="plugin-view-mode">
<button
className={viewMode === 'list' ? 'active' : ''}
onClick={() => setViewMode('list')}
title={t('listView')}
>
<List size={14} />
</button>
<button
className={viewMode === 'grid' ? 'active' : ''}
onClick={() => setViewMode('grid')}
title={t('gridView')}
>
<Grid size={14} />
</button>
</div>
</div>
</div>
) : (
<div className="plugin-categories">
{Object.entries(pluginsByCategory).map(([category, categoryPlugins]) => {
const cat = category as EditorPluginCategory;
const isExpanded = expandedCategories.has(cat);
return (
<div key={category} className="plugin-category">
<div
className="plugin-category-header"
onClick={() => toggleCategory(cat)}
>
<button className="plugin-category-toggle">
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<span className="plugin-category-icon">
{(() => {
const CategoryIcon = (LucideIcons as any)[categoryIcons[cat]];
return CategoryIcon ? <CategoryIcon size={16} /> : null;
})()}
</span>
<span className="plugin-category-name">{getCategoryName(cat)}</span>
<span className="plugin-category-count">
{categoryPlugins.length}
</span>
</div>
<div
className="plugin-content"
style={{ display: activeTab === 'installed' ? 'block' : 'none' }}
>
{plugins.length === 0 ? (
<div className="plugin-empty">
<Package size={48} />
<p>{t('noPlugins')}</p>
</div>
) : (
<div className="plugin-categories">
{Object.entries(pluginsByCategory).map(([category, categoryPlugins]) => {
const cat = category as EditorPluginCategory;
const isExpanded = expandedCategories.has(cat);
{isExpanded && (
<div className={`plugin-category-content ${viewMode}`}>
{viewMode === 'grid'
? categoryPlugins.map(renderPluginCard)
: categoryPlugins.map(renderPluginList)}
return (
<div key={category} className="plugin-category">
<div
className="plugin-category-header"
onClick={() => toggleCategory(cat)}
>
<button className="plugin-category-toggle">
{isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</button>
<span className="plugin-category-icon">
{(() => {
const CategoryIcon = (LucideIcons as any)[
categoryIcons[cat]
];
return CategoryIcon ? <CategoryIcon size={16} /> : null;
})()}
</span>
<span className="plugin-category-name">{getCategoryName(cat)}</span>
<span className="plugin-category-count">
{categoryPlugins.length}
</span>
</div>
{isExpanded && (
<div className={`plugin-category-content ${viewMode}`}>
{viewMode === 'grid'
? categoryPlugins.map(renderPluginCard)
: categoryPlugins.map(renderPluginList)}
</div>
)}
</div>
)}
</div>
);
})}
);
})}
</div>
)}
</div>
)}
</div>
</>
)}
{activeTab === 'marketplace' && (
<PluginMarketPanel
marketService={marketService}
locale={locale}
projectPath={projectPath}
onReloadPlugins={onRefresh}
/>
)}
</div>
</div>
);

View File

@@ -0,0 +1,440 @@
import { useState, useEffect } from 'react';
import * as LucideIcons from 'lucide-react';
import {
Package,
Search,
Download,
CheckCircle,
ExternalLink,
Github,
Star,
AlertCircle,
RefreshCw,
Filter
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import type { PluginMarketService, PluginMarketMetadata } from '../services/PluginMarketService';
import '../styles/PluginMarketPanel.css';
interface PluginMarketPanelProps {
marketService: PluginMarketService;
locale: string;
projectPath: string | null;
onReloadPlugins?: () => Promise<void>;
}
export function PluginMarketPanel({ marketService, locale, projectPath, onReloadPlugins }: PluginMarketPanelProps) {
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
title: '插件市场',
searchPlaceholder: '搜索插件...',
loading: '加载中...',
loadError: '无法连接到插件市场',
loadErrorDesc: '可能是网络连接问题,请检查您的网络设置后重试',
retry: '重试',
noPlugins: '没有找到插件',
install: '安装',
installed: '已安装',
update: '更新',
uninstall: '卸载',
viewSource: '查看源码',
official: '官方',
verified: '认证',
community: '社区',
filterAll: '全部',
filterOfficial: '官方插件',
filterCommunity: '社区插件',
categoryAll: '全部分类',
installing: '安装中...',
uninstalling: '卸载中...',
useDirectSource: '使用直连源',
useDirectSourceTip: '启用后直接从GitHub获取数据绕过CDN缓存适合测试',
latest: '最新',
releaseNotes: '更新日志',
selectVersion: '选择版本',
noProjectOpen: '请先打开一个项目'
},
en: {
title: 'Plugin Marketplace',
searchPlaceholder: 'Search plugins...',
loading: 'Loading...',
loadError: 'Unable to connect to plugin marketplace',
loadErrorDesc: 'This might be a network connection issue. Please check your network settings and try again',
retry: 'Retry',
noPlugins: 'No plugins found',
install: 'Install',
installed: 'Installed',
update: 'Update',
uninstall: 'Uninstall',
viewSource: 'View Source',
official: 'Official',
verified: 'Verified',
community: 'Community',
filterAll: 'All',
filterOfficial: 'Official',
filterCommunity: 'Community',
categoryAll: 'All Categories',
installing: 'Installing...',
uninstalling: 'Uninstalling...',
useDirectSource: 'Direct Source',
useDirectSourceTip: 'Fetch data directly from GitHub, bypassing CDN cache (for testing)',
latest: 'Latest',
releaseNotes: 'Release Notes',
selectVersion: 'Select Version',
noProjectOpen: 'Please open a project first'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
const [plugins, setPlugins] = useState<PluginMarketMetadata[]>([]);
const [filteredPlugins, setFilteredPlugins] = useState<PluginMarketMetadata[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [typeFilter, setTypeFilter] = useState<'all' | 'official' | 'community'>('all');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [installingPlugins, setInstallingPlugins] = useState<Set<string>>(new Set());
const [useDirectSource, setUseDirectSource] = useState(marketService.isUsingDirectSource());
useEffect(() => {
loadPlugins();
}, []);
useEffect(() => {
filterPlugins();
}, [plugins, searchQuery, typeFilter, categoryFilter]);
const loadPlugins = async (bypassCache: boolean = false) => {
setLoading(true);
setError(null);
try {
const pluginList = await marketService.fetchPluginList(bypassCache);
setPlugins(pluginList);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
const filterPlugins = () => {
let filtered = plugins;
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
p.description.toLowerCase().includes(query) ||
p.tags?.some((tag) => tag.toLowerCase().includes(query))
);
}
if (typeFilter !== 'all') {
filtered = filtered.filter((p) => p.category_type === typeFilter);
}
if (categoryFilter !== 'all') {
filtered = filtered.filter((p) => p.category === categoryFilter);
}
setFilteredPlugins(filtered);
};
const handleToggleDirectSource = () => {
const newValue = !useDirectSource;
setUseDirectSource(newValue);
marketService.setUseDirectSource(newValue);
loadPlugins(true);
};
const handleInstall = async (plugin: PluginMarketMetadata, version?: string) => {
if (!projectPath) {
alert(t('noProjectOpen') || 'Please open a project first');
return;
}
setInstallingPlugins((prev) => new Set(prev).add(plugin.id));
try {
await marketService.installPlugin(plugin, version, onReloadPlugins);
setPlugins([...plugins]);
} catch (error) {
console.error('Failed to install plugin:', error);
alert(`Failed to install ${plugin.name}: ${error}`);
} finally {
setInstallingPlugins((prev) => {
const next = new Set(prev);
next.delete(plugin.id);
return next;
});
}
};
const handleUninstall = async (plugin: PluginMarketMetadata) => {
if (!confirm(`Are you sure you want to uninstall ${plugin.name}?`)) {
return;
}
setInstallingPlugins((prev) => new Set(prev).add(plugin.id));
try {
await marketService.uninstallPlugin(plugin.id, onReloadPlugins);
setPlugins([...plugins]);
} catch (error) {
console.error('Failed to uninstall plugin:', error);
alert(`Failed to uninstall ${plugin.name}: ${error}`);
} finally {
setInstallingPlugins((prev) => {
const next = new Set(prev);
next.delete(plugin.id);
return next;
});
}
};
const categories = ['all', ...Array.from(new Set(plugins.map((p) => p.category)))];
if (loading) {
return (
<div className="plugin-market-loading">
<RefreshCw size={32} className="spinning" />
<p>{t('loading')}</p>
</div>
);
}
if (error) {
return (
<div className="plugin-market-error">
<AlertCircle size={64} className="error-icon" />
<h3>{t('loadError')}</h3>
<p className="error-description">{t('loadErrorDesc')}</p>
<div className="error-details">
<p className="error-message">{error}</p>
</div>
<button className="retry-button" onClick={() => loadPlugins(true)}>
<RefreshCw size={16} />
{t('retry')}
</button>
</div>
);
}
return (
<div className="plugin-market-panel">
<div className="plugin-market-toolbar">
<div className="plugin-market-search">
<Search size={16} />
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="plugin-market-filters">
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as any)}
className="plugin-market-filter-select"
>
<option value="all">{t('filterAll')}</option>
<option value="official">{t('filterOfficial')}</option>
<option value="community">{t('filterCommunity')}</option>
</select>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="plugin-market-filter-select"
>
<option value="all">{t('categoryAll')}</option>
{categories
.filter((c) => c !== 'all')
.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
<label className="plugin-market-direct-source-toggle" title={t('useDirectSourceTip')}>
<input
type="checkbox"
checked={useDirectSource}
onChange={handleToggleDirectSource}
/>
<span className="toggle-label">{t('useDirectSource')}</span>
</label>
<button className="plugin-market-refresh" onClick={() => loadPlugins(true)} title={t('retry')}>
<RefreshCw size={16} />
</button>
</div>
</div>
<div className="plugin-market-content">
{filteredPlugins.length === 0 ? (
<div className="plugin-market-empty">
<Package size={48} />
<p>{t('noPlugins')}</p>
</div>
) : (
<div className="plugin-market-grid">
{filteredPlugins.map((plugin) => (
<PluginMarketCard
key={plugin.id}
plugin={plugin}
isInstalled={marketService.isInstalled(plugin.id)}
hasUpdate={marketService.hasUpdate(plugin)}
isInstalling={installingPlugins.has(plugin.id)}
onInstall={(version) => handleInstall(plugin, version)}
onUninstall={() => handleUninstall(plugin)}
t={t}
/>
))}
</div>
)}
</div>
</div>
);
}
interface PluginMarketCardProps {
plugin: PluginMarketMetadata;
isInstalled: boolean;
hasUpdate: boolean;
isInstalling: boolean;
onInstall: (version?: string) => void;
onUninstall: () => void;
t: (key: string) => string;
}
function PluginMarketCard({
plugin,
isInstalled,
hasUpdate,
isInstalling,
onInstall,
onUninstall,
t
}: PluginMarketCardProps) {
const [selectedVersion, setSelectedVersion] = useState(plugin.latestVersion);
const [showVersions, setShowVersions] = useState(false);
const IconComponent = plugin.icon ? (LucideIcons as any)[plugin.icon] : Package;
const selectedVersionData = plugin.versions.find((v) => v.version === selectedVersion);
const multipleVersions = plugin.versions.length > 1;
return (
<div className="plugin-market-card">
<div className="plugin-market-card-header">
<div className="plugin-market-card-icon">
<IconComponent size={32} />
</div>
<div className="plugin-market-card-info">
<div className="plugin-market-card-title">
<span>{plugin.name}</span>
{plugin.verified && (
<span className="plugin-market-badge official" title={t('official')}>
<CheckCircle size={14} />
</span>
)}
</div>
<div className="plugin-market-card-meta">
<span className="plugin-market-card-author">
<Github size={12} />
{plugin.author.name}
</span>
{multipleVersions ? (
<select
className="plugin-market-version-select"
value={selectedVersion}
onChange={(e) => setSelectedVersion(e.target.value)}
onClick={(e) => e.stopPropagation()}
title={t('selectVersion')}
>
{plugin.versions.map((v) => (
<option key={v.version} value={v.version}>
v{v.version} {v.version === plugin.latestVersion ? `(${t('latest')})` : ''}
</option>
))}
</select>
) : (
<span className="plugin-market-card-version">v{plugin.latestVersion}</span>
)}
</div>
</div>
</div>
<div className="plugin-market-card-description">{plugin.description}</div>
{selectedVersionData && selectedVersionData.changes && (
<details className="plugin-market-version-changes">
<summary>{t('releaseNotes')}</summary>
<p>{selectedVersionData.changes}</p>
</details>
)}
{plugin.tags && plugin.tags.length > 0 && (
<div className="plugin-market-card-tags">
{plugin.tags.slice(0, 3).map((tag) => (
<span key={tag} className="plugin-market-tag">
{tag}
</span>
))}
</div>
)}
<div className="plugin-market-card-footer">
<button
className="plugin-market-card-link"
onClick={async (e) => {
e.stopPropagation();
try {
await open(plugin.repository.url);
} catch (error) {
console.error('Failed to open URL:', error);
}
}}
>
<ExternalLink size={14} />
{t('viewSource')}
</button>
<div className="plugin-market-card-actions">
{isInstalling ? (
<button className="plugin-market-btn installing" disabled>
<RefreshCw size={14} className="spinning" />
{isInstalled ? t('uninstalling') : t('installing')}
</button>
) : isInstalled ? (
<>
{hasUpdate && (
<button className="plugin-market-btn update" onClick={() => onInstall(plugin.latestVersion)}>
<Download size={14} />
{t('update')}
</button>
)}
<button className="plugin-market-btn installed" onClick={onUninstall}>
<CheckCircle size={14} />
{t('uninstall')}
</button>
</>
) : (
<button className="plugin-market-btn install" onClick={() => onInstall(selectedVersion)}>
<Download size={14} />
{t('install')}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,948 @@
import { useState } from 'react';
import { X, AlertCircle, CheckCircle, Loader, ExternalLink, FolderOpen, FileArchive } from 'lucide-react';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { GitHubService } from '../services/GitHubService';
import { GitHubAuth } from './GitHubAuth';
import { PluginPublishService, type PluginPublishInfo, type PublishProgress } from '../services/PluginPublishService';
import { PluginBuildService, type BuildProgress } from '../services/PluginBuildService';
import { PluginSourceParser, type ParsedPluginInfo } from '../services/PluginSourceParser';
import { open } from '@tauri-apps/plugin-shell';
import { EditorPluginCategory, type IEditorPluginMetadata } from '@esengine/editor-core';
import '../styles/PluginPublishWizard.css';
interface PluginPublishWizardProps {
githubService: GitHubService;
onClose: () => void;
locale: string;
inline?: boolean; // 是否内联显示(在 tab 中)而不是弹窗
}
type Step = 'auth' | 'selectSource' | 'info' | 'building' | 'confirm' | 'publishing' | 'success' | 'error';
type SourceType = 'folder' | 'zip';
function calculateNextVersion(currentVersion: string): string {
const parts = currentVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some(isNaN)) return currentVersion;
const [major, minor, patch] = parts;
return `${major}.${minor}.${(patch ?? 0) + 1}`;
}
export function PluginPublishWizard({ githubService, onClose, locale, inline = false }: PluginPublishWizardProps) {
const [publishService] = useState(() => new PluginPublishService(githubService));
const [buildService] = useState(() => new PluginBuildService());
const [sourceParser] = useState(() => new PluginSourceParser());
const [step, setStep] = useState<Step>(githubService.isAuthenticated() ? 'selectSource' : 'auth');
const [sourceType, setSourceType] = useState<SourceType | null>(null);
const [parsedPluginInfo, setParsedPluginInfo] = useState<ParsedPluginInfo | null>(null);
const [publishInfo, setPublishInfo] = useState<Partial<PluginPublishInfo>>({
category: 'community',
tags: []
});
const [prUrl, setPrUrl] = useState('');
const [error, setError] = useState('');
const [buildLog, setBuildLog] = useState<string[]>([]);
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
const [publishProgress, setPublishProgress] = useState<PublishProgress | null>(null);
const [builtZipPath, setBuiltZipPath] = useState<string>('');
const [existingPR, setExistingPR] = useState<{ number: number; url: string } | null>(null);
const [existingVersions, setExistingVersions] = useState<string[]>([]);
const [suggestedVersion, setSuggestedVersion] = useState<string>('');
const [existingManifest, setExistingManifest] = useState<any>(null);
const [isUpdate, setIsUpdate] = useState(false);
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
title: '发布插件到市场',
updateTitle: '更新插件版本',
stepAuth: '步骤 1: GitHub 登录',
stepSelectSource: '步骤 2: 选择插件源',
stepInfo: '步骤 3: 插件信息',
stepInfoUpdate: '步骤 3: 版本更新',
stepBuilding: '步骤 4: 构建打包',
stepConfirm: '步骤 5: 确认发布',
stepConfirmNoBuilding: '步骤 4: 确认发布',
githubLogin: 'GitHub 登录',
oauthLogin: 'OAuth 登录(推荐)',
tokenLogin: 'Token 登录',
oauthInstructions: '点击下方按钮开始授权:',
oauthStep1: '1. 点击"开始授权"按钮',
oauthStep2: '2. 在浏览器中打开 GitHub 授权页面',
oauthStep3: '3. 输入下方显示的代码并授权',
oauthStep4: '4. 授权完成后会自动跳转',
startAuth: '开始授权',
authorizing: '等待授权中...',
authorized: '授权成功!',
authFailed: '授权失败',
userCode: '授权码',
copyCode: '复制代码',
openBrowser: '打开浏览器',
tokenLabel: 'GitHub Personal Access Token',
tokenPlaceholder: '粘贴你的 GitHub Token',
tokenHint: '需要 repo 和 workflow 权限',
createToken: '创建 Token',
login: '登录',
switchToToken: '使用 Token 登录',
switchToOAuth: '使用 OAuth 登录',
selectSource: '选择插件源',
selectSourceDesc: '选择插件的来源类型',
selectFolder: '选择源代码文件夹',
selectFolderDesc: '选择包含你的插件源代码的文件夹(需要有 package.json系统将自动构建',
selectZip: '选择 ZIP 文件',
selectZipDesc: '选择已构建好的插件 ZIP 包(必须包含 package.json 和 dist 目录)',
zipRequirements: 'ZIP 文件要求',
zipStructure: 'ZIP 结构',
zipStructureDetails: 'ZIP 文件必须包含以下内容:',
zipFile1: 'package.json - 插件元数据',
zipFile2: 'dist/ - 构建后的代码目录(包含 index.esm.js',
zipExample: '示例结构',
zipBuildScript: '打包脚本',
zipBuildScriptDesc: '可以使用以下命令打包:',
recommendFolder: '💡 建议使用"源代码文件夹"方式,系统会自动构建',
browseFolder: '浏览文件夹',
browseZip: '浏览 ZIP 文件',
selectedFolder: '已选择文件夹',
selectedZip: '已选择 ZIP',
sourceTypeFolder: '源代码文件夹',
sourceTypeZip: 'ZIP 文件',
pluginInfo: '插件信息',
version: '版本号',
currentVersion: '当前版本',
suggestedVersion: '建议版本',
versionHistory: '版本历史',
updatePlugin: '更新插件',
newPlugin: '新插件',
category: '分类',
official: '官方',
community: '社区',
repositoryUrl: '仓库地址',
repositoryPlaceholder: 'https://github.com/username/repo',
releaseNotes: '更新说明',
releaseNotesPlaceholder: '描述这个版本的变更...',
tags: '标签(逗号分隔)',
tagsPlaceholder: 'ui, tool, editor',
homepage: '主页 URL可选',
next: '下一步',
back: '上一步',
build: '构建并打包',
building: '构建中...',
confirm: '确认发布',
publishing: '发布中...',
publishSuccess: '发布成功!',
publishError: '发布失败',
buildError: '构建失败',
prCreated: 'Pull Request 已创建',
viewPR: '查看 PR',
close: '关闭',
buildingStep1: '正在安装依赖...',
buildingStep2: '正在构建项目...',
buildingStep3: '正在打包 ZIP...',
publishingStep1: '正在 Fork 仓库...',
publishingStep2: '正在创建分支...',
publishingStep3: '正在上传文件...',
publishingStep4: '正在创建 Pull Request...',
confirmMessage: '确认要发布以下插件?',
reviewMessage: '你的插件提交已创建 PR维护者将进行审核。审核通过后插件将自动发布到市场。',
existingPRDetected: '检测到现有 PR',
existingPRMessage: '该插件已有待审核的 PR #{{number}}。点击"确认"将更新现有 PR不会创建新的 PR。',
viewExistingPR: '查看现有 PR'
},
en: {
title: 'Publish Plugin to Marketplace',
updateTitle: 'Update Plugin Version',
stepAuth: 'Step 1: GitHub Authentication',
stepSelectSource: 'Step 2: Select Plugin Source',
stepInfo: 'Step 3: Plugin Information',
stepInfoUpdate: 'Step 3: Version Update',
stepBuilding: 'Step 4: Build & Package',
stepConfirm: 'Step 5: Confirm Publication',
stepConfirmNoBuilding: 'Step 4: Confirm Publication',
githubLogin: 'GitHub Login',
oauthLogin: 'OAuth Login (Recommended)',
tokenLogin: 'Token Login',
oauthInstructions: 'Click the button below to start authorization:',
oauthStep1: '1. Click "Start Authorization"',
oauthStep2: '2. Open GitHub authorization page in browser',
oauthStep3: '3. Enter the code shown below and authorize',
oauthStep4: '4. Authorization will complete automatically',
startAuth: 'Start Authorization',
authorizing: 'Waiting for authorization...',
authorized: 'Authorized!',
authFailed: 'Authorization failed',
userCode: 'Authorization Code',
copyCode: 'Copy Code',
openBrowser: 'Open Browser',
tokenLabel: 'GitHub Personal Access Token',
tokenPlaceholder: 'Paste your GitHub Token',
tokenHint: 'Requires repo and workflow permissions',
createToken: 'Create Token',
login: 'Login',
switchToToken: 'Use Token Login',
switchToOAuth: 'Use OAuth Login',
selectSource: 'Select Plugin Source',
selectSourceDesc: 'Choose the plugin source type',
selectFolder: 'Select Source Folder',
selectFolderDesc: 'Select the folder containing your plugin source code (must have package.json, will be built automatically)',
selectZip: 'Select ZIP File',
selectZipDesc: 'Select a pre-built plugin ZIP package (must contain package.json and dist directory)',
zipRequirements: 'ZIP File Requirements',
zipStructure: 'ZIP Structure',
zipStructureDetails: 'The ZIP file must contain:',
zipFile1: 'package.json - Plugin metadata',
zipFile2: 'dist/ - Built code directory (with index.esm.js)',
zipExample: 'Example Structure',
zipBuildScript: 'Build Script',
zipBuildScriptDesc: 'You can use the following commands to package:',
recommendFolder: '💡 Recommended: Use "Source Folder" mode for automatic build',
browseFolder: 'Browse Folder',
browseZip: 'Browse ZIP File',
selectedFolder: 'Selected Folder',
selectedZip: 'Selected ZIP',
sourceTypeFolder: 'Source Folder',
sourceTypeZip: 'ZIP File',
pluginInfo: 'Plugin Information',
version: 'Version',
currentVersion: 'Current Version',
suggestedVersion: 'Suggested Version',
versionHistory: 'Version History',
updatePlugin: 'Update Plugin',
newPlugin: 'New Plugin',
category: 'Category',
official: 'Official',
community: 'Community',
repositoryUrl: 'Repository URL',
repositoryPlaceholder: 'https://github.com/username/repo',
releaseNotes: 'Release Notes',
releaseNotesPlaceholder: 'Describe the changes in this version...',
tags: 'Tags (comma separated)',
tagsPlaceholder: 'ui, tool, editor',
homepage: 'Homepage URL (optional)',
next: 'Next',
back: 'Back',
build: 'Build & Package',
building: 'Building...',
confirm: 'Confirm & Publish',
publishing: 'Publishing...',
publishSuccess: 'Published Successfully!',
publishError: 'Publication Failed',
buildError: 'Build Failed',
prCreated: 'Pull Request Created',
viewPR: 'View PR',
close: 'Close',
buildingStep1: 'Installing dependencies...',
buildingStep2: 'Building project...',
buildingStep3: 'Packaging ZIP...',
publishingStep1: 'Forking repository...',
publishingStep2: 'Creating branch...',
publishingStep3: 'Uploading files...',
publishingStep4: 'Creating Pull Request...',
confirmMessage: 'Confirm publishing this plugin?',
reviewMessage:
'Your plugin submission has been created as a PR. Maintainers will review it. Once approved, the plugin will be published to the marketplace.',
existingPRDetected: 'Existing PR Detected',
existingPRMessage: 'This plugin already has a pending PR #{{number}}. Clicking "Confirm" will update the existing PR (no new PR will be created).',
viewExistingPR: 'View Existing PR'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
const handleAuthSuccess = () => {
setStep('selectSource');
};
/**
* 选择并解析插件源(文件夹或 ZIP
* 统一处理逻辑,避免代码重复
*/
const handleSelectSource = async (type: SourceType) => {
setError('');
setSourceType(type);
try {
let parsedInfo: ParsedPluginInfo;
if (type === 'folder') {
// 选择文件夹
const selected = await openDialog({
directory: true,
multiple: false,
title: t('selectFolder')
});
if (!selected) return;
// 使用 PluginSourceParser 解析文件夹
parsedInfo = await sourceParser.parseFromFolder(selected as string);
} else {
// 选择 ZIP 文件
const selected = await openDialog({
directory: false,
multiple: false,
title: t('selectZip'),
filters: [
{
name: 'ZIP Files',
extensions: ['zip']
}
]
});
if (!selected) return;
// 使用 PluginSourceParser 解析 ZIP
parsedInfo = await sourceParser.parseFromZip(selected as string);
}
// 验证 package.json
sourceParser.validatePackageJson(parsedInfo.packageJson);
setParsedPluginInfo(parsedInfo);
// 检测已发布的版本
await checkExistingVersions(parsedInfo.packageJson);
// 检测是否已有待审核的 PR
await checkExistingPR(parsedInfo.packageJson);
// 进入下一步
setStep('info');
} catch (err) {
console.error('[PluginPublishWizard] Failed to parse plugin source:', err);
setError(err instanceof Error ? err.message : 'Failed to parse plugin source');
}
};
/**
* 检测插件是否已发布,获取版本信息
*/
const checkExistingVersions = async (packageJson: { name: string; version: string }) => {
try {
const pluginId = sourceParser.generatePluginId(packageJson.name);
const manifestContent = await githubService.getFileContent(
'esengine',
'ecs-editor-plugins',
`plugins/community/${pluginId}/manifest.json`,
'main'
);
const manifest = JSON.parse(manifestContent);
if (Array.isArray(manifest.versions)) {
const versions = manifest.versions.map((v: any) => v.version);
setExistingVersions(versions);
setExistingManifest(manifest);
setIsUpdate(true);
// 计算建议版本号
const latestVersion = manifest.latestVersion || versions[0];
const suggested = calculateNextVersion(latestVersion);
setSuggestedVersion(suggested);
// 更新模式:自动填充现有信息
setPublishInfo((prev) => ({
...prev,
version: suggested,
repositoryUrl: manifest.repository?.url || '',
category: manifest.category_type || 'community',
tags: manifest.tags || [],
homepage: manifest.homepage
}));
} else {
// 首次发布
resetToNewPlugin(packageJson.version);
}
} catch (err) {
console.log('[PluginPublishWizard] No existing versions found, this is a new plugin');
resetToNewPlugin(packageJson.version);
}
};
/**
* 重置为新插件状态
*/
const resetToNewPlugin = (version: string) => {
setExistingVersions([]);
setExistingManifest(null);
setIsUpdate(false);
setPublishInfo((prev) => ({
...prev,
version
}));
};
/**
* 检测是否已有待审核的 PR
*/
const checkExistingPR = async (packageJson: { name: string; version: string }) => {
try {
const user = githubService.getUser();
if (user) {
const branchName = `add-plugin-${packageJson.name}-v${packageJson.version}`;
const headBranch = `${user.login}:${branchName}`;
const pr = await githubService.findPullRequestByBranch('esengine', 'ecs-editor-plugins', headBranch);
if (pr) {
setExistingPR({ number: pr.number, url: pr.html_url });
} else {
setExistingPR(null);
}
}
} catch (err) {
console.log('[PluginPublishWizard] Failed to check existing PR:', err);
setExistingPR(null);
}
};
/**
* 从信息填写步骤进入下一步
* - 如果是 ZIP直接跳到确认发布
* - 如果是文件夹,需要先构建
*/
const handleNext = () => {
if (!publishInfo.version || !publishInfo.repositoryUrl || !publishInfo.releaseNotes) {
setError('Please fill in all required fields');
return;
}
if (!parsedPluginInfo) {
setError('Plugin source not selected');
return;
}
// ZIP 文件已经构建好,直接跳到确认步骤
if (parsedPluginInfo.sourceType === 'zip' && parsedPluginInfo.zipPath) {
setBuiltZipPath(parsedPluginInfo.zipPath);
setStep('confirm');
} else {
// 文件夹需要构建
setStep('building');
handleBuild();
}
};
/**
* 构建插件(仅对文件夹源有效)
*/
const handleBuild = async () => {
if (!parsedPluginInfo || parsedPluginInfo.sourceType !== 'folder') {
setError('Cannot build: plugin source is not a folder');
setStep('error');
return;
}
setBuildLog([]);
setBuildProgress(null);
setError('');
buildService.setProgressCallback((progress) => {
console.log('[PluginPublishWizard] Build progress:', progress);
setBuildProgress(progress);
if (progress.step === 'install') {
setBuildLog((prev) => {
if (prev[prev.length - 1] !== t('buildingStep1')) {
return [...prev, t('buildingStep1')];
}
return prev;
});
} else if (progress.step === 'build') {
setBuildLog((prev) => {
if (prev[prev.length - 1] !== t('buildingStep2')) {
return [...prev, t('buildingStep2')];
}
return prev;
});
} else if (progress.step === 'package') {
setBuildLog((prev) => {
if (prev[prev.length - 1] !== t('buildingStep3')) {
return [...prev, t('buildingStep3')];
}
return prev;
});
} else if (progress.step === 'complete') {
setBuildLog((prev) => [...prev, t('buildComplete')]);
}
if (progress.output) {
console.log('[Build output]', progress.output);
}
});
try {
const zipPath = await buildService.buildPlugin(parsedPluginInfo.sourcePath);
console.log('[PluginPublishWizard] Build completed, ZIP at:', zipPath);
setBuiltZipPath(zipPath);
setStep('confirm');
} catch (err) {
console.error('[PluginPublishWizard] Build failed:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
setStep('error');
}
};
/**
* 发布插件到市场
*/
const handlePublish = async () => {
setStep('publishing');
setError('');
setPublishProgress(null);
// 设置进度回调
publishService.setProgressCallback((progress) => {
setPublishProgress(progress);
});
try {
// 验证必填字段
if (!publishInfo.version || !publishInfo.repositoryUrl || !publishInfo.releaseNotes) {
throw new Error('Missing required fields');
}
// 验证插件源
if (!parsedPluginInfo) {
throw new Error('Plugin source not selected');
}
// 验证 ZIP 路径
if (!builtZipPath) {
throw new Error('Plugin ZIP file not available');
}
const { packageJson } = parsedPluginInfo;
const pluginMetadata: IEditorPluginMetadata = {
name: packageJson.name,
displayName: packageJson.description || packageJson.name,
description: packageJson.description || '',
version: packageJson.version,
category: EditorPluginCategory.Tool,
icon: 'Package',
enabled: true,
installedAt: Date.now()
};
const fullPublishInfo: PluginPublishInfo = {
pluginMetadata,
version: publishInfo.version || packageJson.version,
releaseNotes: publishInfo.releaseNotes || '',
repositoryUrl: publishInfo.repositoryUrl || '',
category: publishInfo.category || 'community',
tags: publishInfo.tags,
homepage: publishInfo.homepage,
screenshots: publishInfo.screenshots
};
console.log('[PluginPublishWizard] Publishing with info:', fullPublishInfo);
console.log('[PluginPublishWizard] Built ZIP path:', builtZipPath);
const prUrl = await publishService.publishPlugin(fullPublishInfo, builtZipPath);
setPrUrl(prUrl);
setStep('success');
} catch (err) {
console.error('[PluginPublishWizard] Publish failed:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
setStep('error');
}
};
const openPR = async () => {
if (prUrl) {
await open(prUrl);
}
};
const wizardContent = (
<div className={inline ? "plugin-publish-wizard inline" : "plugin-publish-wizard"} onClick={(e) => inline ? undefined : e.stopPropagation()}>
<div className="plugin-publish-header">
<h2>{t('title')}</h2>
{!inline && (
<button className="plugin-publish-close" onClick={onClose}>
<X size={20} />
</button>
)}
</div>
<div className="plugin-publish-content">
{step === 'auth' && (
<div className="publish-step">
<h3>{t('stepAuth')}</h3>
<GitHubAuth
githubService={githubService}
onSuccess={handleAuthSuccess}
locale={locale}
/>
</div>
)}
{step === 'selectSource' && (
<div className="publish-step">
<h3>{t('stepSelectSource')}</h3>
<p>{t('selectSourceDesc')}</p>
<div className="source-type-selection">
<button
className={`source-type-btn ${sourceType === 'folder' ? 'active' : ''}`}
onClick={() => handleSelectSource('folder')}
>
<FolderOpen size={24} />
<div className="source-type-info">
<strong>{t('sourceTypeFolder')}</strong>
<p>{t('selectFolderDesc')}</p>
</div>
</button>
<button
className={`source-type-btn ${sourceType === 'zip' ? 'active' : ''}`}
onClick={() => handleSelectSource('zip')}
>
<FileArchive size={24} />
<div className="source-type-info">
<strong>{t('sourceTypeZip')}</strong>
<p>{t('selectZipDesc')}</p>
</div>
</button>
</div>
{/* ZIP 文件要求说明 */}
<details className="zip-requirements-details">
<summary>
<AlertCircle size={16} />
{t('zipRequirements')}
</summary>
<div className="zip-requirements-content">
<div className="requirement-section">
<h4>{t('zipStructure')}</h4>
<p>{t('zipStructureDetails')}</p>
<ul>
<li><code>package.json</code> - {t('zipFile1')}</li>
<li><code>dist/</code> - {t('zipFile2')}</li>
</ul>
</div>
<div className="requirement-section">
<h4>{t('zipBuildScript')}</h4>
<p>{t('zipBuildScriptDesc')}</p>
<pre className="build-script-example">
{`npm install
npm run build
# 然后将 package.json 和 dist/ 目录一起压缩为 ZIP
# ZIP 结构:
# plugin.zip
# ├── package.json
# └── dist/
# └── index.esm.js`}
</pre>
</div>
<div className="recommendation-notice">
{t('recommendFolder')}
</div>
</div>
</details>
{parsedPluginInfo && (
<div className="selected-source">
{parsedPluginInfo.sourceType === 'folder' ? (
<FolderOpen size={20} />
) : (
<FileArchive size={20} />
)}
<div className="source-details">
<span className="source-path">{parsedPluginInfo.sourcePath}</span>
<span className="source-name">{parsedPluginInfo.packageJson.name} v{parsedPluginInfo.packageJson.version}</span>
</div>
</div>
)}
{error && (
<div className="error-message">
<AlertCircle size={16} />
{error}
</div>
)}
{parsedPluginInfo && (
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('auth')}>
{t('back')}
</button>
</div>
)}
</div>
)}
{step === 'info' && (
<div className="publish-step">
<h3>{t('stepInfo')}</h3>
{existingPR && (
<div className="existing-pr-notice">
<AlertCircle size={20} />
<div className="notice-content">
<strong>{t('existingPRDetected')}</strong>
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
<button
className="btn-link"
onClick={() => open(existingPR.url)}
>
<ExternalLink size={16} />
{t('viewExistingPR')}
</button>
</div>
</div>
)}
<div className="form-group">
<label>{t('version')} *</label>
{isUpdate && (
<div className="version-info">
<div className="version-notice">
<CheckCircle size={16} />
<span>{t('updatePlugin')}: {existingManifest?.name} v{existingVersions[0]}</span>
</div>
{suggestedVersion && (
<button
type="button"
className="btn-version-suggest"
onClick={() => setPublishInfo({ ...publishInfo, version: suggestedVersion })}
>
{t('suggestedVersion')}: {suggestedVersion}
</button>
)}
</div>
)}
<input
type="text"
value={publishInfo.version || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, version: e.target.value })}
placeholder="1.0.0"
/>
{isUpdate && (
<details className="version-history">
<summary>{t('versionHistory')} ({existingVersions.length})</summary>
<ul>
{existingVersions.map((v) => (
<li key={v}>v{v}</li>
))}
</ul>
</details>
)}
</div>
<div className="form-group">
<label>{t('releaseNotes')} *</label>
<textarea
rows={4}
value={publishInfo.releaseNotes || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, releaseNotes: e.target.value })}
placeholder={t('releaseNotesPlaceholder')}
/>
</div>
{!isUpdate && (
<>
<div className="form-group">
<label>{t('category')} *</label>
<select
value={publishInfo.category}
onChange={(e) =>
setPublishInfo({ ...publishInfo, category: e.target.value as 'official' | 'community' })
}
>
<option value="community">{t('community')}</option>
<option value="official">{t('official')}</option>
</select>
</div>
<div className="form-group">
<label>{t('repositoryUrl')} *</label>
<input
type="text"
value={publishInfo.repositoryUrl || ''}
onChange={(e) => setPublishInfo({ ...publishInfo, repositoryUrl: e.target.value })}
placeholder={t('repositoryPlaceholder')}
/>
</div>
<div className="form-group">
<label>{t('tags')}</label>
<input
type="text"
value={publishInfo.tags?.join(', ') || ''}
onChange={(e) =>
setPublishInfo({
...publishInfo,
tags: e.target.value
.split(',')
.map((t) => t.trim())
.filter(Boolean)
})
}
placeholder={t('tagsPlaceholder')}
/>
</div>
</>
)}
{error && (
<div className="error-message">
<AlertCircle size={16} />
{error}
</div>
)}
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('selectSource')}>
{t('back')}
</button>
<button className="btn-primary" onClick={handleNext}>
{sourceType === 'zip' ? t('next') : t('build')}
</button>
</div>
</div>
)}
{step === 'building' && (
<div className="publish-step publishing">
<Loader size={48} className="spinning" />
<h3>{t('building')}</h3>
<div className="build-log">
{buildLog.map((log, i) => (
<div key={i} className="log-line">
<CheckCircle size={16} style={{ color: '#34c759', flexShrink: 0 }} />
<span>{log}</span>
</div>
))}
</div>
</div>
)}
{step === 'confirm' && (
<div className="publish-step">
<h3>{t('stepConfirm')}</h3>
<p>{t('confirmMessage')}</p>
{existingPR && (
<div className="existing-pr-notice">
<AlertCircle size={20} />
<div className="notice-content">
<strong>{t('existingPRDetected')}</strong>
<p>{t('existingPRMessage').replace('{{number}}', String(existingPR.number))}</p>
<button
className="btn-link"
onClick={() => open(existingPR.url)}
>
<ExternalLink size={16} />
{t('viewExistingPR')}
</button>
</div>
</div>
)}
<div className="confirm-details">
<div className="detail-row">
<span className="detail-label">{t('selectSource')}:</span>
<span className="detail-value">
{parsedPluginInfo?.sourceType === 'zip' ? t('selectedZip') : t('selectedFolder')}: {parsedPluginInfo?.sourcePath}
</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('version')}:</span>
<span className="detail-value">{publishInfo.version}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('category')}:</span>
<span className="detail-value">{t(publishInfo.category!)}</span>
</div>
<div className="detail-row">
<span className="detail-label">{t('repositoryUrl')}:</span>
<span className="detail-value">{publishInfo.repositoryUrl}</span>
</div>
{builtZipPath && (
<div className="detail-row">
<span className="detail-label">Package Path:</span>
<span className="detail-value" style={{ fontSize: '12px', wordBreak: 'break-all' }}>
{builtZipPath}
</span>
</div>
)}
</div>
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('info')}>
{t('back')}
</button>
<button className="btn-primary" onClick={handlePublish}>
{t('confirm')}
</button>
</div>
</div>
)}
{step === 'publishing' && (
<div className="publish-step publishing">
<Loader size={48} className="spinning" />
<h3>{t('publishing')}</h3>
{publishProgress && (
<div className="publish-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${publishProgress.progress}%` }}
/>
</div>
<p className="progress-message">{publishProgress.message}</p>
<p className="progress-percent">{publishProgress.progress}%</p>
</div>
)}
</div>
)}
{step === 'success' && (
<div className="publish-step success">
<CheckCircle size={48} style={{ color: '#34c759' }} />
<h3>{t('publishSuccess')}</h3>
<p>{t('prCreated')}</p>
<p className="review-message">{t('reviewMessage')}</p>
<button className="btn-link" onClick={openPR}>
<ExternalLink size={14} />
{t('viewPR')}
</button>
<button className="btn-primary" onClick={onClose}>
{t('close')}
</button>
</div>
)}
{step === 'error' && (
<div className="publish-step error">
<AlertCircle size={48} style={{ color: '#ff3b30' }} />
<h3>{t('publishError')}</h3>
<p>{error}</p>
<div className="button-group">
<button className="btn-secondary" onClick={() => setStep('info')}>
{t('back')}
</button>
<button className="btn-primary" onClick={onClose}>
{t('close')}
</button>
</div>
</div>
)}
</div>
</div>
);
return inline ? wizardContent : (
<div className="plugin-publish-overlay" onClick={onClose}>
{wizardContent}
</div>
);
}

View File

@@ -0,0 +1,354 @@
import { useState } from 'react';
import { X, FolderOpen, Loader, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import type { GitHubService, PublishedPlugin } from '../services/GitHubService';
import { PluginPublishService, type PublishProgress } from '../services/PluginPublishService';
import { PluginBuildService, type BuildProgress } from '../services/PluginBuildService';
import { open } from '@tauri-apps/plugin-shell';
import { EditorPluginCategory } from '@esengine/editor-core';
import type { IEditorPluginMetadata } from '@esengine/editor-core';
import '../styles/PluginUpdateDialog.css';
interface PluginUpdateDialogProps {
plugin: PublishedPlugin;
githubService: GitHubService;
onClose: () => void;
onSuccess: () => void;
locale: string;
}
type Step = 'selectFolder' | 'info' | 'building' | 'publishing' | 'success' | 'error';
function calculateNextVersion(currentVersion: string): string {
const parts = currentVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some(isNaN)) return currentVersion;
const [major, minor, patch] = parts;
return `${major}.${minor}.${(patch ?? 0) + 1}`;
}
export function PluginUpdateDialog({ plugin, githubService, onClose, onSuccess, locale }: PluginUpdateDialogProps) {
const [publishService] = useState(() => new PluginPublishService(githubService));
const [buildService] = useState(() => new PluginBuildService());
const [step, setStep] = useState<Step>('selectFolder');
const [pluginFolder, setPluginFolder] = useState('');
const [version, setVersion] = useState('');
const [releaseNotes, setReleaseNotes] = useState('');
const [suggestedVersion] = useState(() => calculateNextVersion(plugin.latestVersion));
const [error, setError] = useState('');
const [buildLog, setBuildLog] = useState<string[]>([]);
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
const [publishProgress, setPublishProgress] = useState<PublishProgress | null>(null);
const [prUrl, setPrUrl] = useState('');
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
title: '更新插件',
currentVersion: '当前版本',
newVersion: '新版本号',
useSuggested: '使用建议版本',
releaseNotes: '更新说明',
releaseNotesPlaceholder: '描述这个版本的变更...',
selectFolder: '选择插件文件夹',
selectFolderDesc: '选择包含更新后插件源代码的文件夹',
browseFolder: '浏览文件夹',
selectedFolder: '已选择文件夹',
next: '下一步',
back: '上一步',
buildAndPublish: '构建并发布',
building: '构建中...',
publishing: '发布中...',
success: '更新成功!',
error: '更新失败',
viewPR: '查看 PR',
close: '关闭',
buildError: '构建失败',
publishError: '发布失败',
buildingStep1: '正在安装依赖...',
buildingStep2: '正在构建项目...',
buildingStep3: '正在打包 ZIP...',
publishingStep1: '正在 Fork 仓库...',
publishingStep2: '正在创建分支...',
publishingStep3: '正在上传文件...',
publishingStep4: '正在创建 Pull Request...',
reviewMessage: '你的插件更新已创建 PR维护者将进行审核。审核通过后新版本将自动发布到市场。'
},
en: {
title: 'Update Plugin',
currentVersion: 'Current Version',
newVersion: 'New Version',
useSuggested: 'Use Suggested',
releaseNotes: 'Release Notes',
releaseNotesPlaceholder: 'Describe the changes in this version...',
selectFolder: 'Select Plugin Folder',
selectFolderDesc: 'Select the folder containing your updated plugin source code',
browseFolder: 'Browse Folder',
selectedFolder: 'Selected Folder',
next: 'Next',
back: 'Back',
buildAndPublish: 'Build & Publish',
building: 'Building...',
publishing: 'Publishing...',
success: 'Update Successful!',
error: 'Update Failed',
viewPR: 'View PR',
close: 'Close',
buildError: 'Build Failed',
publishError: 'Publish Failed',
buildingStep1: 'Installing dependencies...',
buildingStep2: 'Building project...',
buildingStep3: 'Packaging ZIP...',
publishingStep1: 'Forking repository...',
publishingStep2: 'Creating branch...',
publishingStep3: 'Uploading files...',
publishingStep4: 'Creating Pull Request...',
reviewMessage: 'Your plugin update has been created as a PR. Maintainers will review it. Once approved, the new version will be published to the marketplace.'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
const handleSelectFolder = async () => {
try {
const selected = await openDialog({
directory: true,
multiple: false,
title: t('selectFolder')
});
if (!selected) return;
setPluginFolder(selected as string);
setStep('info');
} catch (err) {
console.error('[PluginUpdateDialog] Failed to select folder:', err);
setError(err instanceof Error ? err.message : 'Failed to select folder');
}
};
const handleBuildAndPublish = async () => {
if (!version || !releaseNotes) {
alert('Please fill in all required fields');
return;
}
setStep('building');
setBuildLog([]);
setError('');
try {
buildService.setProgressCallback((progress) => {
setBuildProgress(progress);
if (progress.output) {
setBuildLog((prev) => [...prev, progress.output!]);
}
});
const zipPath = await buildService.buildPlugin(pluginFolder);
console.log('[PluginUpdateDialog] Build completed:', zipPath);
setStep('publishing');
publishService.setProgressCallback((progress) => {
setPublishProgress(progress);
});
const { readTextFile } = await import('@tauri-apps/plugin-fs');
const packageJsonPath = `${pluginFolder}/package.json`;
const packageJsonContent = await readTextFile(packageJsonPath);
const pkgJson = JSON.parse(packageJsonContent);
const pluginMetadata: IEditorPluginMetadata = {
name: pkgJson.name,
displayName: pkgJson.description || pkgJson.name,
description: pkgJson.description || '',
version: pkgJson.version,
category: EditorPluginCategory.Tool,
icon: 'Package',
enabled: true,
installedAt: Date.now()
};
const publishInfo = {
pluginMetadata,
version,
releaseNotes,
category: plugin.category_type as 'official' | 'community',
repositoryUrl: plugin.repositoryUrl || '',
tags: []
};
const prUrl = await publishService.publishPlugin(publishInfo, zipPath);
console.log('[PluginUpdateDialog] Update published:', prUrl);
setPrUrl(prUrl);
setStep('success');
onSuccess();
} catch (err) {
console.error('[PluginUpdateDialog] Failed to update plugin:', err);
setError(err instanceof Error ? err.message : 'Update failed');
setStep('error');
}
};
const renderStepContent = () => {
switch (step) {
case 'selectFolder':
return (
<div className="update-dialog-step">
<h3>{t('selectFolder')}</h3>
<p className="step-description">{t('selectFolderDesc')}</p>
<button className="btn-browse" onClick={handleSelectFolder}>
<FolderOpen size={16} />
{t('browseFolder')}
</button>
</div>
);
case 'info':
return (
<div className="update-dialog-step">
<div className="current-plugin-info">
<h4>{plugin.name}</h4>
<p>
{t('currentVersion')}: <strong>v{plugin.latestVersion}</strong>
</p>
</div>
{pluginFolder && (
<div className="selected-folder-info">
<FolderOpen size={16} />
<span>{pluginFolder}</span>
</div>
)}
<div className="form-group">
<label>{t('newVersion')} *</label>
<div className="version-input-group">
<input
type="text"
value={version}
onChange={(e) => setVersion(e.target.value)}
placeholder={suggestedVersion}
/>
<button
type="button"
className="btn-suggest"
onClick={() => setVersion(suggestedVersion)}
>
{t('useSuggested')} ({suggestedVersion})
</button>
</div>
</div>
<div className="form-group">
<label>{t('releaseNotes')} *</label>
<textarea
rows={6}
value={releaseNotes}
onChange={(e) => setReleaseNotes(e.target.value)}
placeholder={t('releaseNotesPlaceholder')}
/>
</div>
<div className="update-dialog-actions">
<button className="btn-back" onClick={() => setStep('selectFolder')}>
{t('back')}
</button>
<button
className="btn-primary"
onClick={handleBuildAndPublish}
disabled={!version || !releaseNotes}
>
{t('buildAndPublish')}
</button>
</div>
</div>
);
case 'building':
return (
<div className="update-dialog-step">
<h3>{t('building')}</h3>
{buildProgress && (
<div className="progress-container">
<p className="progress-message">{buildProgress.message}</p>
</div>
)}
{buildLog.length > 0 && (
<div className="build-log">
{buildLog.map((log, index) => (
<div key={index} className="log-line">
{log}
</div>
))}
</div>
)}
</div>
);
case 'publishing':
return (
<div className="update-dialog-step">
<h3>{t('publishing')}</h3>
{publishProgress && (
<div className="progress-container">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${publishProgress.progress}%` }} />
</div>
<p className="progress-message">{publishProgress.message}</p>
</div>
)}
</div>
);
case 'success':
return (
<div className="update-dialog-step success-step">
<CheckCircle size={64} className="success-icon" />
<h3>{t('success')}</h3>
<p className="success-message">{t('reviewMessage')}</p>
{prUrl && (
<button className="btn-view-pr" onClick={() => open(prUrl)}>
{t('viewPR')}
</button>
)}
<button className="btn-close" onClick={onClose}>
{t('close')}
</button>
</div>
);
case 'error':
return (
<div className="update-dialog-step error-step">
<AlertCircle size={64} className="error-icon" />
<h3>{t('error')}</h3>
<p className="error-message">{error}</p>
<button className="btn-close" onClick={onClose}>
{t('close')}
</button>
</div>
);
default:
return null;
}
};
return (
<div className="plugin-update-dialog-overlay">
<div className="plugin-update-dialog">
<div className="update-dialog-header">
<h2>{t('title')}: {plugin.name}</h2>
<button className="update-dialog-close" onClick={onClose}>
<X size={20} />
</button>
</div>
<div className="update-dialog-content">{renderStepContent()}</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,159 @@
import { useState, useRef, useEffect } from 'react';
import { Github, LogOut, User, LayoutDashboard, Loader2 } from 'lucide-react';
import type { GitHubService, GitHubUser } from '../services/GitHubService';
import '../styles/UserProfile.css';
interface UserProfileProps {
githubService: GitHubService;
onLogin: () => void;
onOpenDashboard: () => void;
locale: string;
}
export function UserProfile({ githubService, onLogin, onOpenDashboard, locale }: UserProfileProps) {
const [user, setUser] = useState<GitHubUser | null>(githubService.getUser());
const [showMenu, setShowMenu] = useState(false);
const [isLoadingUser, setIsLoadingUser] = useState(githubService.isLoadingUserInfo());
const menuRef = useRef<HTMLDivElement>(null);
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
login: '登录',
logout: '登出',
dashboard: '个人中心',
profile: '个人信息',
notLoggedIn: '未登录',
loadingUser: '加载中...'
},
en: {
login: 'Login',
logout: 'Logout',
dashboard: 'Dashboard',
profile: 'Profile',
notLoggedIn: 'Not logged in',
loadingUser: 'Loading...'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
// 监听加载状态变化
const unsubscribe = githubService.onUserLoadStateChange((isLoading) => {
console.log('[UserProfile] User load state changed:', isLoading);
setIsLoadingUser(isLoading);
});
return unsubscribe;
}, [githubService]);
useEffect(() => {
// 监听认证状态变化
const checkUser = () => {
const currentUser = githubService.getUser();
setUser((prevUser) => {
if (currentUser && (!prevUser || currentUser.login !== prevUser.login)) {
console.log('[UserProfile] User state changed:', currentUser.login);
return currentUser;
} else if (!currentUser && prevUser) {
console.log('[UserProfile] User logged out');
return null;
}
return prevUser;
});
};
// 每秒检查一次用户状态
const interval = setInterval(checkUser, 1000);
return () => clearInterval(interval);
}, [githubService]);
const handleLogout = () => {
githubService.logout();
setUser(null);
setShowMenu(false);
};
if (!user) {
return (
<div className="user-profile">
<button
className="login-button"
onClick={onLogin}
disabled={isLoadingUser}
title={isLoadingUser ? t('loadingUser') : undefined}
>
{isLoadingUser ? (
<>
<Loader2 size={16} className="spinning" />
{t('loadingUser')}
</>
) : (
<>
<Github size={16} />
{t('login')}
</>
)}
</button>
</div>
);
}
return (
<div className="user-profile" ref={menuRef}>
<button className="user-avatar-button" onClick={() => setShowMenu(!showMenu)}>
{user.avatar_url ? (
<img src={user.avatar_url} alt={user.name} className="user-avatar" />
) : (
<div className="user-avatar-placeholder">
<User size={20} />
</div>
)}
<span className="user-name">{user.name || user.login}</span>
</button>
{showMenu && (
<div className="user-menu">
<div className="user-menu-header">
<img src={user.avatar_url} alt={user.name} className="user-menu-avatar" />
<div className="user-menu-info">
<div className="user-menu-name">{user.name || user.login}</div>
<div className="user-menu-login">@{user.login}</div>
</div>
</div>
<div className="user-menu-divider" />
<button
className="user-menu-item"
onClick={() => {
setShowMenu(false);
onOpenDashboard();
}}
>
<LayoutDashboard size={16} />
{t('dashboard')}
</button>
<button className="user-menu-item" onClick={handleLogout}>
<LogOut size={16} />
{t('logout')}
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,58 @@
import 'reflect-metadata';
import { Core } from '@esengine/ecs-framework';
import { singleton } from 'tsyringe';
import { DIContainer, globalContainer } from '../di/DIContainer';
import { EditorEventBus } from '../events/EditorEventBus';
import { CommandRegistry } from '../commands/CommandRegistry';
import { PanelRegistry } from '../commands/PanelRegistry';
export interface EditorContext {
container: DIContainer;
eventBus: EditorEventBus;
commands: CommandRegistry;
panels: PanelRegistry;
scene: Core;
}
@singleton()
export class EditorBootstrap {
private initialized = false;
async initialize(): Promise<EditorContext> {
if (this.initialized) {
throw new Error('EditorBootstrap has already been initialized');
}
console.log('[EditorBootstrap] Starting editor initialization...');
const scene = await this.createScene();
console.log('[EditorBootstrap] Scene created');
const container = globalContainer;
const eventBus = container.resolve(EditorEventBus);
console.log('[EditorBootstrap] EventBus initialized');
const commands = container.resolve(CommandRegistry);
console.log('[EditorBootstrap] CommandRegistry initialized');
const panels = container.resolve(PanelRegistry);
console.log('[EditorBootstrap] PanelRegistry initialized');
this.initialized = true;
console.log('[EditorBootstrap] Editor initialized successfully');
return {
container,
eventBus,
commands,
panels,
scene
};
}
private async createScene(): Promise<Core> {
const scene = await Core.create();
return scene;
}
}

View File

@@ -0,0 +1 @@
export * from './EditorBootstrap';

View File

@@ -0,0 +1,57 @@
import { singleton } from 'tsyringe';
import type { ICommand, ICommandRegistry, KeyBinding } from '../interfaces/ICommandRegistry';
@singleton()
export class CommandRegistry implements ICommandRegistry {
private commands = new Map<string, ICommand>();
register(command: ICommand): void {
if (this.commands.has(command.id)) {
console.warn(`Command ${command.id} is already registered. Overwriting.`);
}
this.commands.set(command.id, command);
}
unregister(commandId: string): void {
this.commands.delete(commandId);
}
async execute(commandId: string, context?: unknown): Promise<void> {
const command = this.commands.get(commandId);
if (!command) {
throw new Error(`Command ${commandId} not found`);
}
if (command.when && !command.when()) {
console.warn(`Command ${commandId} cannot be executed (when clause failed)`);
return;
}
try {
await command.execute(context);
} catch (error) {
console.error(`Error executing command ${commandId}:`, error);
throw error;
}
}
getCommand(commandId: string): ICommand | undefined {
return this.commands.get(commandId);
}
getCommands(): ICommand[] {
return Array.from(this.commands.values());
}
getKeybindings(): Array<{ command: ICommand; keybinding: KeyBinding }> {
const bindings: Array<{ command: ICommand; keybinding: KeyBinding }> = [];
for (const command of this.commands.values()) {
if (command.keybinding) {
bindings.push({ command, keybinding: command.keybinding });
}
}
return bindings;
}
}

View File

@@ -0,0 +1,34 @@
import { singleton } from 'tsyringe';
import type { IPanelRegistry, PanelDescriptor } from '../interfaces/IPanelRegistry';
@singleton()
export class PanelRegistry implements IPanelRegistry {
private panels = new Map<string, PanelDescriptor>();
register(panel: PanelDescriptor): void {
if (this.panels.has(panel.id)) {
console.warn(`Panel ${panel.id} is already registered. Overwriting.`);
}
this.panels.set(panel.id, panel);
}
unregister(panelId: string): void {
this.panels.delete(panelId);
}
getPanel(panelId: string): PanelDescriptor | undefined {
return this.panels.get(panelId);
}
getPanels(category?: string): PanelDescriptor[] {
const allPanels = Array.from(this.panels.values());
if (!category) {
return allPanels.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
return allPanels
.filter(panel => panel.category === category)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
}

View File

@@ -0,0 +1,2 @@
export * from './CommandRegistry';
export * from './PanelRegistry';

View File

@@ -0,0 +1,52 @@
import 'reflect-metadata';
import { container, DependencyContainer, InjectionToken, Lifecycle } from 'tsyringe';
export class DIContainer {
private readonly container: DependencyContainer;
constructor(parent?: DependencyContainer) {
this.container = parent ? parent.createChildContainer() : container;
}
registerSingleton<T>(token: InjectionToken<T>, implementation?: new (...args: unknown[]) => T): void {
if (implementation) {
this.container.register(token, { useClass: implementation }, { lifecycle: Lifecycle.Singleton });
} else {
this.container.registerSingleton(token as new (...args: unknown[]) => T);
}
}
registerInstance<T>(token: InjectionToken<T>, instance: T): void {
this.container.registerInstance(token, instance);
}
registerTransient<T>(token: InjectionToken<T>, implementation: new (...args: unknown[]) => T): void {
this.container.register(token, { useClass: implementation }, { lifecycle: Lifecycle.Transient });
}
registerFactory<T>(token: InjectionToken<T>, factory: (container: DependencyContainer) => T): void {
this.container.register(token, { useFactory: factory });
}
resolve<T>(token: InjectionToken<T>): T {
return this.container.resolve(token);
}
isRegistered<T>(token: InjectionToken<T>): boolean {
return this.container.isRegistered(token);
}
createChild(): DIContainer {
return new DIContainer(this.container);
}
getNativeContainer(): DependencyContainer {
return this.container;
}
dispose(): void {
this.container.clearInstances();
}
}
export const globalContainer = new DIContainer();

View File

@@ -0,0 +1,7 @@
import { singleton } from 'tsyringe';
import { TypedEventBus } from './TypedEventBus';
import type { EditorEventMap } from './EditorEventMap';
@singleton()
export class EditorEventBus extends TypedEventBus<EditorEventMap> {
}

View File

@@ -0,0 +1,98 @@
import type { Entity, Component } from '@esengine/ecs-framework';
import type { Node } from '@esengine/behavior-tree-editor';
export interface PluginEvent {
name: string;
category?: string;
}
export interface EntityEvent {
entity: Entity;
}
export interface ComponentEvent {
entity: Entity;
component: Component;
}
export interface ComponentPropertyChangedEvent {
entity: Entity;
component: Component;
property: string;
value: unknown;
}
export interface AssetFileEvent {
path: string;
type?: string;
}
export interface BehaviorTreeNodeEvent {
node: Node;
}
export interface BehaviorTreeLoadFileEvent {
filePath: string;
}
export interface DynamicPanelEvent {
panelId: string;
data?: unknown;
}
export interface UIWindowEvent {
windowId: string;
data?: unknown;
}
export interface FullscreenEvent {
fullscreen: boolean;
}
export interface LocaleChangedEvent {
locale: string;
}
export interface NotificationEvent {
type: 'success' | 'error' | 'warning' | 'info';
message: string;
duration?: number;
}
export interface SceneEvent {
scenePath?: string;
}
export interface EditorEventMap extends Record<string, unknown> {
'plugin:installed': PluginEvent;
'plugin:enabled': PluginEvent;
'plugin:disabled': PluginEvent;
'entity:selected': EntityEvent;
'remote-entity:selected': EntityEvent;
'entity:added': EntityEvent;
'entity:removed': EntityEvent;
'entities:cleared': Record<string, never>;
'component:added': ComponentEvent;
'component:removed': ComponentEvent;
'component:property:changed': ComponentPropertyChangedEvent;
'asset:reveal': AssetFileEvent;
'asset-file:selected': AssetFileEvent;
'behavior-tree:node-selected': BehaviorTreeNodeEvent;
'behavior-tree:load-file': BehaviorTreeLoadFileEvent;
'dynamic-panel:open': DynamicPanelEvent;
'ui:openWindow': UIWindowEvent;
'editor:fullscreen': FullscreenEvent;
'locale:changed': LocaleChangedEvent;
'notification:show': NotificationEvent;
'scene:loaded': SceneEvent;
'scene:new': SceneEvent;
'scene:saved': SceneEvent;
'scene:modified': SceneEvent;
}

View File

@@ -0,0 +1,57 @@
import { Subject, Observable, Subscription } from 'rxjs';
import { singleton } from 'tsyringe';
import type { IEventBus, Unsubscribe } from '../interfaces/IEventBus';
@singleton()
export class TypedEventBus<TEvents = Record<string, unknown>> implements IEventBus<TEvents> {
private subjects = new Map<keyof TEvents, Subject<TEvents[keyof TEvents]>>();
private subscriptions: Subscription[] = [];
async publish<K extends keyof TEvents>(topic: K, data: TEvents[K]): Promise<void> {
const subject = this.getSubject(topic);
subject.next(data);
}
subscribe<K extends keyof TEvents>(
topic: K,
handler: (data: TEvents[K]) => void | Promise<void>
): Unsubscribe {
const subscription = this.observe(topic).subscribe(async (data) => {
try {
await handler(data);
} catch (error) {
console.error(`Error in event handler for topic ${String(topic)}:`, error);
}
});
this.subscriptions.push(subscription);
return () => {
subscription.unsubscribe();
const index = this.subscriptions.indexOf(subscription);
if (index !== -1) {
this.subscriptions.splice(index, 1);
}
};
}
observe<K extends keyof TEvents>(topic: K): Observable<TEvents[K]> {
return this.getSubject(topic).asObservable() as Observable<TEvents[K]>;
}
dispose(): void {
this.subscriptions.forEach(sub => sub.unsubscribe());
this.subscriptions = [];
this.subjects.forEach(subject => subject.complete());
this.subjects.clear();
}
private getSubject<K extends keyof TEvents>(topic: K): Subject<TEvents[K]> {
let subject = this.subjects.get(topic) as Subject<TEvents[K]> | undefined;
if (!subject) {
subject = new Subject<TEvents[K]>();
this.subjects.set(topic, subject as unknown as Subject<TEvents[keyof TEvents]>);
}
return subject;
}
}

View File

@@ -0,0 +1,3 @@
export * from './EditorEventMap';
export * from './TypedEventBus';
export * from './EditorEventBus';

View File

@@ -0,0 +1,5 @@
export * from './interfaces';
export * from './di/DIContainer';
export * from './events';
export * from './commands';
export * from './bootstrap';

View File

@@ -0,0 +1,25 @@
export interface KeyBinding {
key: string;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
}
export interface ICommand {
readonly id: string;
readonly label: string;
readonly icon?: string;
readonly keybinding?: KeyBinding;
readonly when?: () => boolean;
execute(context?: unknown): void | Promise<void>;
}
export interface ICommandRegistry {
register(command: ICommand): void;
unregister(commandId: string): void;
execute(commandId: string, context?: unknown): Promise<void>;
getCommand(commandId: string): ICommand | undefined;
getCommands(): ICommand[];
getKeybindings(): Array<{ command: ICommand; keybinding: KeyBinding }>;
}

View File

@@ -0,0 +1,16 @@
import type { Observable } from 'rxjs';
export type Unsubscribe = () => void;
export interface IEventBus<TEvents = Record<string, unknown>> {
publish<K extends keyof TEvents>(topic: K, data: TEvents[K]): Promise<void>;
subscribe<K extends keyof TEvents>(
topic: K,
handler: (data: TEvents[K]) => void | Promise<void>
): Unsubscribe;
observe<K extends keyof TEvents>(topic: K): Observable<TEvents[K]>;
dispose(): void;
}

View File

@@ -0,0 +1,17 @@
import type { ComponentType, ReactNode } from 'react';
export interface PanelDescriptor {
id: string;
title: string;
icon?: ReactNode;
component: ComponentType<unknown>;
category?: string;
order?: number;
}
export interface IPanelRegistry {
register(panel: PanelDescriptor): void;
unregister(panelId: string): void;
getPanel(panelId: string): PanelDescriptor | undefined;
getPanels(category?: string): PanelDescriptor[];
}

View File

@@ -0,0 +1,3 @@
export * from './IEventBus';
export * from './ICommandRegistry';
export * from './IPanelRegistry';

View File

@@ -1,25 +0,0 @@
import { Node } from '../models/Node';
import { Position } from '../value-objects/Position';
import { NodeTemplate } from '@esengine/behavior-tree';
export const ROOT_NODE_ID = 'root-node';
export const createRootNodeTemplate = (): NodeTemplate => ({
type: 'root',
displayName: '根节点',
category: '根节点',
icon: 'TreePine',
description: '行为树根节点',
color: '#FFD700',
maxChildren: 1,
defaultConfig: {
nodeType: 'root'
},
properties: []
});
export const createRootNode = (): Node => {
const template = createRootNodeTemplate();
const position = new Position(400, 100);
return new Node(ROOT_NODE_ID, template, { nodeType: 'root' }, position, []);
};

View File

@@ -1,10 +0,0 @@
/**
* 领域错误基类
*/
export abstract class DomainError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@@ -1,10 +0,0 @@
import { DomainError } from './DomainError';
/**
* 节点未找到错误
*/
export class NodeNotFoundError extends DomainError {
constructor(public readonly nodeId: string) {
super(`节点未找到: ${nodeId}`);
}
}

View File

@@ -1,52 +0,0 @@
import { DomainError } from './DomainError';
/**
* 验证错误
* 当业务规则验证失败时抛出
*/
export class ValidationError extends DomainError {
constructor(
message: string,
public readonly field?: string,
public readonly value?: unknown
) {
super(message);
}
static rootNodeMaxChildren(): ValidationError {
return new ValidationError(
'根节点只能连接一个子节点',
'children'
);
}
static decoratorNodeMaxChildren(): ValidationError {
return new ValidationError(
'装饰节点只能连接一个子节点',
'children'
);
}
static leafNodeNoChildren(): ValidationError {
return new ValidationError(
'叶子节点不能有子节点',
'children'
);
}
static circularReference(nodeId: string): ValidationError {
return new ValidationError(
`检测到循环引用,节点 ${nodeId} 不能连接到自己或其子节点`,
'connection',
nodeId
);
}
static invalidConnection(from: string, to: string, reason: string): ValidationError {
return new ValidationError(
`无效的连接:${reason}`,
'connection',
{ from, to }
);
}
}

View File

@@ -1,3 +0,0 @@
export { DomainError } from './DomainError';
export { ValidationError } from './ValidationError';
export { NodeNotFoundError } from './NodeNotFoundError';

View File

@@ -1,5 +0,0 @@
export * from './models';
export * from './value-objects';
export * from './interfaces';
export { DomainError, ValidationError as DomainValidationError, NodeNotFoundError } from './errors';
export * from './services';

View File

@@ -1,32 +0,0 @@
import { NodeTemplate } from '@esengine/behavior-tree';
import { Node } from '../models/Node';
import { Position } from '../value-objects';
/**
* 节点工厂接口
* 负责创建不同类型的节点
*/
export interface INodeFactory {
/**
* 创建节点
*/
createNode(
template: NodeTemplate,
position: Position,
data?: Record<string, unknown>
): Node;
/**
* 根据模板类型创建节点
*/
createNodeByType(
nodeType: string,
position: Position,
data?: Record<string, unknown>
): Node;
/**
* 克隆节点
*/
cloneNode(node: Node, newPosition?: Position): Node;
}

View File

@@ -1,27 +0,0 @@
import { BehaviorTree } from '../models/BehaviorTree';
/**
* 仓储接口
* 负责行为树的持久化
*/
export interface IBehaviorTreeRepository {
/**
* 保存行为树
*/
save(tree: BehaviorTree, path: string): Promise<void>;
/**
* 加载行为树
*/
load(path: string): Promise<BehaviorTree>;
/**
* 检查文件是否存在
*/
exists(path: string): Promise<boolean>;
/**
* 删除行为树文件
*/
delete(path: string): Promise<void>;
}

View File

@@ -1,30 +0,0 @@
import { BehaviorTree } from '../models/BehaviorTree';
/**
* 序列化格式
*/
export type SerializationFormat = 'json' | 'binary';
/**
* 序列化接口
* 负责行为树的序列化和反序列化
*/
export interface ISerializer {
/**
* 序列化行为树
*/
serialize(tree: BehaviorTree, format: SerializationFormat): string | Uint8Array;
/**
* 反序列化行为树
*/
deserialize(data: string | Uint8Array, format: SerializationFormat): BehaviorTree;
/**
* 导出为运行时资产格式
*/
exportToRuntimeAsset(
tree: BehaviorTree,
format: SerializationFormat
): string | Uint8Array;
}

View File

@@ -1,46 +0,0 @@
import { BehaviorTree } from '../models/BehaviorTree';
import { Node } from '../models/Node';
import { Connection } from '../models/Connection';
/**
* 验证结果
*/
export interface ValidationResult {
isValid: boolean;
errors: ValidationError[];
}
/**
* 验证错误详情
*/
export interface ValidationError {
message: string;
nodeId?: string;
field?: string;
}
/**
* 验证器接口
* 负责行为树的验证逻辑
*/
export interface IValidator {
/**
* 验证整个行为树
*/
validateTree(tree: BehaviorTree): ValidationResult;
/**
* 验证节点
*/
validateNode(node: Node): ValidationResult;
/**
* 验证连接
*/
validateConnection(connection: Connection, tree: BehaviorTree): ValidationResult;
/**
* 验证是否会产生循环引用
*/
validateNoCycles(tree: BehaviorTree): ValidationResult;
}

View File

@@ -1,4 +0,0 @@
export { type INodeFactory } from './INodeFactory';
export { type ISerializer, type SerializationFormat } from './ISerializer';
export { type IBehaviorTreeRepository } from './IRepository';
export { type IValidator, type ValidationResult, type ValidationError } from './IValidator';

View File

@@ -1,353 +0,0 @@
import { Node } from './Node';
import { Connection } from './Connection';
import { Blackboard } from './Blackboard';
import { ValidationError, NodeNotFoundError } from '../errors';
/**
* 行为树聚合根
* 管理整个行为树的节点、连接和黑板
*/
export class BehaviorTree {
private readonly _nodes: Map<string, Node>;
private readonly _connections: Connection[];
private readonly _blackboard: Blackboard;
private readonly _rootNodeId: string | null;
constructor(
nodes: Node[] = [],
connections: Connection[] = [],
blackboard: Blackboard = Blackboard.empty(),
rootNodeId: string | null = null
) {
this._nodes = new Map(nodes.map((node) => [node.id, node]));
this._connections = [...connections];
this._blackboard = blackboard;
this._rootNodeId = rootNodeId;
this.validateTree();
}
get nodes(): ReadonlyArray<Node> {
return Array.from(this._nodes.values());
}
get connections(): ReadonlyArray<Connection> {
return this._connections;
}
get blackboard(): Blackboard {
return this._blackboard;
}
get rootNodeId(): string | null {
return this._rootNodeId;
}
/**
* 获取指定节点
*/
getNode(nodeId: string): Node {
const node = this._nodes.get(nodeId);
if (!node) {
throw new NodeNotFoundError(nodeId);
}
return node;
}
/**
* 检查节点是否存在
*/
hasNode(nodeId: string): boolean {
return this._nodes.has(nodeId);
}
/**
* 添加节点
*/
addNode(node: Node): BehaviorTree {
if (this._nodes.has(node.id)) {
throw new ValidationError(`节点 ${node.id} 已存在`);
}
if (node.isRoot()) {
if (this._rootNodeId) {
throw new ValidationError('行为树只能有一个根节点');
}
return new BehaviorTree(
[...this.nodes, node],
this._connections,
this._blackboard,
node.id
);
}
return new BehaviorTree(
[...this.nodes, node],
this._connections,
this._blackboard,
this._rootNodeId
);
}
/**
* 移除节点
* 会同时移除相关的连接
*/
removeNode(nodeId: string): BehaviorTree {
if (!this._nodes.has(nodeId)) {
throw new NodeNotFoundError(nodeId);
}
const node = this.getNode(nodeId);
const newNodes = Array.from(this.nodes.filter((n) => n.id !== nodeId));
const newConnections = this._connections.filter(
(conn) => conn.from !== nodeId && conn.to !== nodeId
);
const newRootNodeId = node.isRoot() ? null : this._rootNodeId;
return new BehaviorTree(
newNodes,
newConnections,
this._blackboard,
newRootNodeId
);
}
/**
* 更新节点
*/
updateNode(nodeId: string, updater: (node: Node) => Node): BehaviorTree {
const node = this.getNode(nodeId);
const updatedNode = updater(node);
const newNodes = Array.from(this.nodes.map((n) => n.id === nodeId ? updatedNode : n));
return new BehaviorTree(
newNodes,
this._connections,
this._blackboard,
this._rootNodeId
);
}
/**
* 添加连接
* 会验证连接的合法性
*/
addConnection(connection: Connection): BehaviorTree {
const fromNode = this.getNode(connection.from);
const toNode = this.getNode(connection.to);
if (this.hasConnection(connection.from, connection.to)) {
throw new ValidationError(`连接已存在:${connection.from} -> ${connection.to}`);
}
if (this.wouldCreateCycle(connection.from, connection.to)) {
throw ValidationError.circularReference(connection.to);
}
if (connection.isNodeConnection()) {
if (!fromNode.canAddChild()) {
if (fromNode.isRoot()) {
throw ValidationError.rootNodeMaxChildren();
}
if (fromNode.nodeType.isDecorator()) {
throw ValidationError.decoratorNodeMaxChildren();
}
throw new ValidationError(`节点 ${connection.from} 无法添加更多子节点`);
}
if (toNode.nodeType.getMaxChildren() === 0 && toNode.nodeType.isLeaf()) {
}
const updatedFromNode = fromNode.addChild(connection.to);
const newNodes = Array.from(this.nodes.map((n) =>
n.id === connection.from ? updatedFromNode : n
));
return new BehaviorTree(
newNodes,
[...this._connections, connection],
this._blackboard,
this._rootNodeId
);
}
return new BehaviorTree(
Array.from(this.nodes),
[...this._connections, connection],
this._blackboard,
this._rootNodeId
);
}
/**
* 移除连接
*/
removeConnection(from: string, to: string, fromProperty?: string, toProperty?: string): BehaviorTree {
const connection = this._connections.find((c) => c.matches(from, to, fromProperty, toProperty));
if (!connection) {
throw new ValidationError(`连接不存在:${from} -> ${to}`);
}
const newConnections = this._connections.filter((c) => !c.matches(from, to, fromProperty, toProperty));
if (connection.isNodeConnection()) {
const fromNode = this.getNode(from);
const updatedFromNode = fromNode.removeChild(to);
const newNodes = Array.from(this.nodes.map((n) =>
n.id === from ? updatedFromNode : n
));
return new BehaviorTree(
newNodes,
newConnections,
this._blackboard,
this._rootNodeId
);
}
return new BehaviorTree(
Array.from(this.nodes),
newConnections,
this._blackboard,
this._rootNodeId
);
}
/**
* 检查是否存在连接
*/
hasConnection(from: string, to: string): boolean {
return this._connections.some((c) => c.from === from && c.to === to);
}
/**
* 检查是否会创建循环引用
*/
private wouldCreateCycle(from: string, to: string): boolean {
const visited = new Set<string>();
const queue: string[] = [to];
while (queue.length > 0) {
const current = queue.shift()!;
if (current === from) {
return true;
}
if (visited.has(current)) {
continue;
}
visited.add(current);
const childConnections = this._connections.filter((c) => c.from === current && c.isNodeConnection());
childConnections.forEach((conn) => queue.push(conn.to));
}
return false;
}
/**
* 更新黑板
*/
updateBlackboard(updater: (blackboard: Blackboard) => Blackboard): BehaviorTree {
return new BehaviorTree(
Array.from(this.nodes),
this._connections,
updater(this._blackboard),
this._rootNodeId
);
}
/**
* 获取节点的子节点
*/
getChildren(nodeId: string): Node[] {
const node = this.getNode(nodeId);
return node.children.map((childId) => this.getNode(childId));
}
/**
* 获取节点的父节点
*/
getParent(nodeId: string): Node | null {
const parentConnection = this._connections.find(
(c) => c.to === nodeId && c.isNodeConnection()
);
if (!parentConnection) {
return null;
}
return this.getNode(parentConnection.from);
}
/**
* 验证树的完整性
*/
private validateTree(): void {
const rootNodes = this.nodes.filter((n) => n.isRoot());
if (rootNodes.length > 1) {
throw new ValidationError('行为树只能有一个根节点');
}
if (rootNodes.length === 1 && rootNodes[0] && this._rootNodeId !== rootNodes[0].id) {
throw new ValidationError('根节点ID不匹配');
}
this._connections.forEach((conn) => {
if (!this._nodes.has(conn.from)) {
throw new NodeNotFoundError(conn.from);
}
if (!this._nodes.has(conn.to)) {
throw new NodeNotFoundError(conn.to);
}
});
}
/**
* 转换为普通对象
*/
toObject(): {
nodes: ReturnType<Node['toObject']>[];
connections: ReturnType<Connection['toObject']>[];
blackboard: Record<string, unknown>;
rootNodeId: string | null;
} {
return {
nodes: this.nodes.map((n) => n.toObject()),
connections: this._connections.map((c) => c.toObject()),
blackboard: this._blackboard.toObject(),
rootNodeId: this._rootNodeId
};
}
/**
* 从普通对象创建行为树
*/
static fromObject(obj: {
nodes: Parameters<typeof Node.fromObject>[0][];
connections: Parameters<typeof Connection.fromObject>[0][];
blackboard: Record<string, unknown>;
rootNodeId: string | null;
}): BehaviorTree {
return new BehaviorTree(
obj.nodes.map((n) => Node.fromObject(n)),
obj.connections.map((c) => Connection.fromObject(c)),
Blackboard.fromObject(obj.blackboard),
obj.rootNodeId
);
}
/**
* 创建空行为树
*/
static empty(): BehaviorTree {
return new BehaviorTree();
}
}

View File

@@ -1,122 +0,0 @@
/**
* 黑板值类型
*/
export type BlackboardValue = string | number | boolean | null | undefined | Record<string, unknown> | unknown[];
/**
* 黑板领域实体
* 管理行为树的全局变量
*/
export class Blackboard {
private _variables: Map<string, BlackboardValue>;
constructor(variables: Record<string, BlackboardValue> = {}) {
this._variables = new Map(Object.entries(variables));
}
/**
* 获取变量值
*/
get(key: string): BlackboardValue {
return this._variables.get(key);
}
/**
* 设置变量值
*/
set(key: string, value: BlackboardValue): Blackboard {
const newVariables = new Map(this._variables);
newVariables.set(key, value);
return new Blackboard(Object.fromEntries(newVariables));
}
/**
* 设置变量值(别名方法)
*/
setValue(key: string, value: BlackboardValue): void {
this._variables.set(key, value);
}
/**
* 删除变量
*/
delete(key: string): Blackboard {
const newVariables = new Map(this._variables);
newVariables.delete(key);
return new Blackboard(Object.fromEntries(newVariables));
}
/**
* 检查变量是否存在
*/
has(key: string): boolean {
return this._variables.has(key);
}
/**
* 获取所有变量名
*/
keys(): string[] {
return Array.from(this._variables.keys());
}
/**
* 获取所有变量
*/
getAll(): Record<string, BlackboardValue> {
return Object.fromEntries(this._variables);
}
/**
* 批量设置变量
*/
setAll(variables: Record<string, BlackboardValue>): Blackboard {
const newVariables = new Map(this._variables);
Object.entries(variables).forEach(([key, value]) => {
newVariables.set(key, value);
});
return new Blackboard(Object.fromEntries(newVariables));
}
/**
* 清空所有变量
*/
clear(): Blackboard {
return new Blackboard();
}
/**
* 获取变量数量
*/
size(): number {
return this._variables.size;
}
/**
* 克隆黑板
*/
clone(): Blackboard {
return new Blackboard(this.getAll());
}
/**
* 转换为普通对象
*/
toObject(): Record<string, BlackboardValue> {
return this.getAll();
}
/**
* 从普通对象创建黑板
*/
static fromObject(obj: Record<string, unknown>): Blackboard {
return new Blackboard(obj as Record<string, BlackboardValue>);
}
/**
* 创建空黑板
*/
static empty(): Blackboard {
return new Blackboard();
}
}

View File

@@ -1,140 +0,0 @@
import { ValidationError } from '../errors';
/**
* 连接类型
*/
export type ConnectionType = 'node' | 'property';
/**
* 连接领域实体
* 表示两个节点之间的连接关系
*/
export class Connection {
private readonly _from: string;
private readonly _to: string;
private readonly _fromProperty?: string;
private readonly _toProperty?: string;
private readonly _connectionType: ConnectionType;
constructor(
from: string,
to: string,
connectionType: ConnectionType = 'node',
fromProperty?: string,
toProperty?: string
) {
if (from === to) {
throw ValidationError.circularReference(from);
}
if (connectionType === 'property' && (!fromProperty || !toProperty)) {
throw new ValidationError('属性连接必须指定源属性和目标属性');
}
this._from = from;
this._to = to;
this._connectionType = connectionType;
this._fromProperty = fromProperty;
this._toProperty = toProperty;
}
get from(): string {
return this._from;
}
get to(): string {
return this._to;
}
get fromProperty(): string | undefined {
return this._fromProperty;
}
get toProperty(): string | undefined {
return this._toProperty;
}
get connectionType(): ConnectionType {
return this._connectionType;
}
/**
* 检查是否为节点连接
*/
isNodeConnection(): boolean {
return this._connectionType === 'node';
}
/**
* 检查是否为属性连接
*/
isPropertyConnection(): boolean {
return this._connectionType === 'property';
}
/**
* 检查连接是否匹配指定的条件
*/
matches(from: string, to: string, fromProperty?: string, toProperty?: string): boolean {
if (this._from !== from || this._to !== to) {
return false;
}
if (this._connectionType === 'property') {
return this._fromProperty === fromProperty && this._toProperty === toProperty;
}
return true;
}
/**
* 相等性比较
*/
equals(other: Connection): boolean {
return (
this._from === other._from &&
this._to === other._to &&
this._connectionType === other._connectionType &&
this._fromProperty === other._fromProperty &&
this._toProperty === other._toProperty
);
}
/**
* 转换为普通对象
*/
toObject(): {
from: string;
to: string;
fromProperty?: string;
toProperty?: string;
connectionType: ConnectionType;
} {
return {
from: this._from,
to: this._to,
connectionType: this._connectionType,
...(this._fromProperty && { fromProperty: this._fromProperty }),
...(this._toProperty && { toProperty: this._toProperty })
};
}
/**
* 从普通对象创建连接
*/
static fromObject(obj: {
from: string;
to: string;
fromProperty?: string;
toProperty?: string;
connectionType: ConnectionType;
}): Connection {
return new Connection(
obj.from,
obj.to,
obj.connectionType,
obj.fromProperty,
obj.toProperty
);
}
}

View File

@@ -1,190 +0,0 @@
import { NodeTemplate } from '@esengine/behavior-tree';
import { Position, NodeType } from '../value-objects';
import { ValidationError } from '../errors';
/**
* 行为树节点领域实体
* 封装节点的业务逻辑和验证规则
*/
export class Node {
private readonly _id: string;
private readonly _template: NodeTemplate;
private _data: Record<string, unknown>;
private _position: Position;
private _children: string[];
private readonly _nodeType: NodeType;
constructor(
id: string,
template: NodeTemplate,
data: Record<string, unknown>,
position: Position,
children: string[] = []
) {
this._id = id;
this._template = template;
this._data = { ...data };
this._position = position;
this._children = [...children];
this._nodeType = NodeType.fromString(template.type);
}
get id(): string {
return this._id;
}
get template(): NodeTemplate {
return this._template;
}
get data(): Record<string, unknown> {
return { ...this._data };
}
get position(): Position {
return this._position;
}
get children(): ReadonlyArray<string> {
return this._children;
}
get nodeType(): NodeType {
return this._nodeType;
}
/**
* 更新节点位置
*/
moveToPosition(newPosition: Position): Node {
return new Node(
this._id,
this._template,
this._data,
newPosition,
this._children
);
}
/**
* 更新节点数据
*/
updateData(data: Record<string, unknown>): Node {
return new Node(
this._id,
this._template,
{ ...this._data, ...data },
this._position,
this._children
);
}
/**
* 添加子节点
* @throws ValidationError 如果违反业务规则
*/
addChild(childId: string): Node {
// 使用模板定义的约束undefined 表示无限制
const maxChildren = (this._template.maxChildren ?? Infinity) as number;
if (maxChildren === 0) {
throw ValidationError.leafNodeNoChildren();
}
if (this._children.length >= maxChildren) {
if (this._nodeType.isRoot()) {
throw ValidationError.rootNodeMaxChildren();
}
if (this._nodeType.isDecorator()) {
throw ValidationError.decoratorNodeMaxChildren();
}
throw new ValidationError(`节点 ${this._id} 已达到最大子节点数 ${maxChildren}`);
}
if (this._children.includes(childId)) {
throw new ValidationError(`子节点 ${childId} 已存在`);
}
return new Node(
this._id,
this._template,
this._data,
this._position,
[...this._children, childId]
);
}
/**
* 移除子节点
*/
removeChild(childId: string): Node {
return new Node(
this._id,
this._template,
this._data,
this._position,
this._children.filter((id) => id !== childId)
);
}
/**
* 检查是否可以添加子节点
*/
canAddChild(): boolean {
// 使用模板定义的最大子节点数undefined 表示无限制
const maxChildren = (this._template.maxChildren ?? Infinity) as number;
return this._children.length < maxChildren;
}
/**
* 检查是否有子节点
*/
hasChildren(): boolean {
return this._children.length > 0;
}
/**
* 检查是否为根节点
*/
isRoot(): boolean {
return this._nodeType.isRoot();
}
/**
* 转换为普通对象(用于序列化)
*/
toObject(): {
id: string;
template: NodeTemplate;
data: Record<string, unknown>;
position: { x: number; y: number };
children: string[];
} {
return {
id: this._id,
template: this._template,
data: this._data,
position: this._position.toObject(),
children: [...this._children]
};
}
/**
* 从普通对象创建节点
*/
static fromObject(obj: {
id: string;
template: NodeTemplate;
data: Record<string, unknown>;
position: { x: number; y: number };
children: string[];
}): Node {
return new Node(
obj.id,
obj.template,
obj.data,
Position.fromObject(obj.position),
obj.children
);
}
}

View File

@@ -1,4 +0,0 @@
export { Node } from './Node';
export { Connection, type ConnectionType } from './Connection';
export { Blackboard, type BlackboardValue } from './Blackboard';
export { BehaviorTree } from './BehaviorTree';

View File

@@ -1,198 +0,0 @@
import { BehaviorTree } from '../models/BehaviorTree';
import { Node } from '../models/Node';
import { Connection } from '../models/Connection';
import { IValidator, ValidationResult, ValidationError as IValidationError } from '../interfaces/IValidator';
/**
* 行为树验证服务
* 实现所有业务验证规则
*/
export class TreeValidator implements IValidator {
/**
* 验证整个行为树
*/
validateTree(tree: BehaviorTree): ValidationResult {
const errors: IValidationError[] = [];
if (!tree.rootNodeId) {
errors.push({
message: '行为树必须有一个根节点'
});
}
const rootNodes = tree.nodes.filter((n) => n.isRoot());
if (rootNodes.length > 1) {
errors.push({
message: '行为树只能有一个根节点',
nodeId: rootNodes.map((n) => n.id).join(', ')
});
}
tree.nodes.forEach((node) => {
const nodeValidation = this.validateNode(node);
errors.push(...nodeValidation.errors);
});
tree.connections.forEach((connection) => {
const connValidation = this.validateConnection(connection, tree);
errors.push(...connValidation.errors);
});
const cycleValidation = this.validateNoCycles(tree);
errors.push(...cycleValidation.errors);
return {
isValid: errors.length === 0,
errors
};
}
/**
* 验证节点
*/
validateNode(node: Node): ValidationResult {
const errors: IValidationError[] = [];
// 使用模板定义的约束undefined 表示无限制
const maxChildren = (node.template.maxChildren ?? Infinity) as number;
const actualChildren = node.children.length;
if (actualChildren > maxChildren) {
if (node.isRoot()) {
errors.push({
message: '根节点只能连接一个子节点',
nodeId: node.id,
field: 'children'
});
} else if (node.nodeType.isDecorator()) {
errors.push({
message: '装饰节点只能连接一个子节点',
nodeId: node.id,
field: 'children'
});
} else if (node.nodeType.isLeaf()) {
errors.push({
message: '叶子节点不能有子节点',
nodeId: node.id,
field: 'children'
});
} else {
errors.push({
message: `节点子节点数量 (${actualChildren}) 超过最大限制 (${maxChildren})`,
nodeId: node.id,
field: 'children'
});
}
}
return {
isValid: errors.length === 0,
errors
};
}
/**
* 验证连接
*/
validateConnection(connection: Connection, tree: BehaviorTree): ValidationResult {
const errors: IValidationError[] = [];
if (!tree.hasNode(connection.from)) {
errors.push({
message: `源节点不存在: ${connection.from}`,
nodeId: connection.from
});
}
if (!tree.hasNode(connection.to)) {
errors.push({
message: `目标节点不存在: ${connection.to}`,
nodeId: connection.to
});
}
if (connection.from === connection.to) {
errors.push({
message: '节点不能连接到自己',
nodeId: connection.from
});
}
if (tree.hasNode(connection.from) && tree.hasNode(connection.to)) {
const fromNode = tree.getNode(connection.from);
const toNode = tree.getNode(connection.to);
if (connection.isNodeConnection()) {
if (!fromNode.canAddChild()) {
errors.push({
message: `节点 ${connection.from} 无法添加更多子节点`,
nodeId: connection.from
});
}
if (toNode.nodeType.isLeaf() && toNode.hasChildren()) {
errors.push({
message: `叶子节点 ${connection.to} 不能有子节点`,
nodeId: connection.to
});
}
}
}
return {
isValid: errors.length === 0,
errors
};
}
/**
* 验证是否存在循环引用
*/
validateNoCycles(tree: BehaviorTree): ValidationResult {
const errors: IValidationError[] = [];
const visited = new Set<string>();
const recursionStack = new Set<string>();
const dfs = (nodeId: string): boolean => {
if (recursionStack.has(nodeId)) {
errors.push({
message: `检测到循环引用: 节点 ${nodeId}`,
nodeId
});
return true;
}
if (visited.has(nodeId)) {
return false;
}
visited.add(nodeId);
recursionStack.add(nodeId);
const node = tree.getNode(nodeId);
for (const childId of node.children) {
if (dfs(childId)) {
return true;
}
}
recursionStack.delete(nodeId);
return false;
};
if (tree.rootNodeId) {
dfs(tree.rootNodeId);
}
tree.nodes.forEach((node) => {
if (!visited.has(node.id) && !node.isRoot()) {
dfs(node.id);
}
});
return {
isValid: errors.length === 0,
errors
};
}
}

View File

@@ -1 +0,0 @@
export { TreeValidator } from './TreeValidator';

View File

@@ -1,107 +0,0 @@
/**
* 节点类型值对象
* 封装节点类型的业务逻辑
*/
export class NodeType {
private readonly _value: string;
private constructor(value: string) {
this._value = value;
}
get value(): string {
return this._value;
}
/**
* 是否为根节点
*/
isRoot(): boolean {
return this._value === 'root';
}
/**
* 是否为组合节点(可以有多个子节点)
*/
isComposite(): boolean {
return this._value === 'composite' ||
['sequence', 'selector', 'parallel'].includes(this._value);
}
/**
* 是否为装饰节点(只能有一个子节点)
*/
isDecorator(): boolean {
return this._value === 'decorator' ||
['repeater', 'inverter', 'succeeder', 'failer', 'until-fail', 'until-success'].includes(this._value);
}
/**
* 是否为叶子节点(不能有子节点)
*/
isLeaf(): boolean {
return this._value === 'action' || this._value === 'condition' ||
this._value.includes('action-') || this._value.includes('condition-');
}
/**
* 获取允许的最大子节点数
* @returns 0 表示叶子节点1 表示装饰节点Infinity 表示组合节点
*/
getMaxChildren(): number {
if (this.isLeaf()) {
return 0;
}
if (this.isRoot() || this.isDecorator()) {
return 1;
}
if (this.isComposite()) {
return Infinity;
}
return 0;
}
/**
* 值对象相等性比较
*/
equals(other: NodeType): boolean {
return this._value === other._value;
}
toString(): string {
return this._value;
}
/**
* 预定义的节点类型
*/
static readonly ROOT = new NodeType('root');
static readonly SEQUENCE = new NodeType('sequence');
static readonly SELECTOR = new NodeType('selector');
static readonly PARALLEL = new NodeType('parallel');
static readonly REPEATER = new NodeType('repeater');
static readonly INVERTER = new NodeType('inverter');
static readonly SUCCEEDER = new NodeType('succeeder');
static readonly FAILER = new NodeType('failer');
static readonly UNTIL_FAIL = new NodeType('until-fail');
static readonly UNTIL_SUCCESS = new NodeType('until-success');
/**
* 从字符串创建节点类型
*/
static fromString(value: string): NodeType {
switch (value) {
case 'root': return NodeType.ROOT;
case 'sequence': return NodeType.SEQUENCE;
case 'selector': return NodeType.SELECTOR;
case 'parallel': return NodeType.PARALLEL;
case 'repeater': return NodeType.REPEATER;
case 'inverter': return NodeType.INVERTER;
case 'succeeder': return NodeType.SUCCEEDER;
case 'failer': return NodeType.FAILER;
case 'until-fail': return NodeType.UNTIL_FAIL;
case 'until-success': return NodeType.UNTIL_SUCCESS;
default: return new NodeType(value);
}
}
}

View File

@@ -1,72 +0,0 @@
/**
* 位置值对象
* 表示二维空间中的坐标点
*/
export class Position {
private readonly _x: number;
private readonly _y: number;
constructor(x: number, y: number) {
this._x = x;
this._y = y;
}
get x(): number {
return this._x;
}
get y(): number {
return this._y;
}
/**
* 创建新的位置,加上偏移量
*/
add(offset: Position): Position {
return new Position(this._x + offset._x, this._y + offset._y);
}
/**
* 创建新的位置,减去偏移量
*/
subtract(other: Position): Position {
return new Position(this._x - other._x, this._y - other._y);
}
/**
* 计算到另一个位置的距离
*/
distanceTo(other: Position): number {
const dx = this._x - other._x;
const dy = this._y - other._y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* 值对象相等性比较
*/
equals(other: Position): boolean {
return this._x === other._x && this._y === other._y;
}
/**
* 转换为普通对象
*/
toObject(): { x: number; y: number } {
return { x: this._x, y: this._y };
}
/**
* 从普通对象创建
*/
static fromObject(obj: { x: number; y: number }): Position {
return new Position(obj.x, obj.y);
}
/**
* 创建零位置
*/
static zero(): Position {
return new Position(0, 0);
}
}

View File

@@ -1,59 +0,0 @@
/**
* 尺寸值对象
* 表示宽度和高度
*/
export class Size {
private readonly _width: number;
private readonly _height: number;
constructor(width: number, height: number) {
if (width < 0 || height < 0) {
throw new Error('Size dimensions must be non-negative');
}
this._width = width;
this._height = height;
}
get width(): number {
return this._width;
}
get height(): number {
return this._height;
}
/**
* 获取面积
*/
get area(): number {
return this._width * this._height;
}
/**
* 缩放尺寸
*/
scale(factor: number): Size {
return new Size(this._width * factor, this._height * factor);
}
/**
* 值对象相等性比较
*/
equals(other: Size): boolean {
return this._width === other._width && this._height === other._height;
}
/**
* 转换为普通对象
*/
toObject(): { width: number; height: number } {
return { width: this._width, height: this._height };
}
/**
* 从普通对象创建
*/
static fromObject(obj: { width: number; height: number }): Size {
return new Size(obj.width, obj.height);
}
}

View File

@@ -1,3 +0,0 @@
export { Position } from './Position';
export { Size } from './Size';
export { NodeType } from './NodeType';

View File

@@ -1,501 +0,0 @@
import { GlobalBlackboardConfig, BlackboardValueType } from '@esengine/behavior-tree';
/**
* 类型生成配置选项
*/
export interface TypeGenerationOptions {
/** 常量名称大小写风格 */
constantCase?: 'UPPER_SNAKE' | 'camelCase' | 'PascalCase';
/** 常量对象名称 */
constantsName?: string;
/** 接口名称 */
interfaceName?: string;
/** 类型别名名称 */
typeAliasName?: string;
/** 包装类名称 */
wrapperClassName?: string;
/** 默认值对象名称 */
defaultsName?: string;
/** 导入路径 */
importPath?: string;
/** 是否生成常量对象 */
includeConstants?: boolean;
/** 是否生成接口 */
includeInterface?: boolean;
/** 是否生成类型别名 */
includeTypeAlias?: boolean;
/** 是否生成包装类 */
includeWrapperClass?: boolean;
/** 是否生成默认值 */
includeDefaults?: boolean;
/** 自定义头部注释 */
customHeader?: string;
/** 使用单引号还是双引号 */
quoteStyle?: 'single' | 'double';
/** 是否在文件末尾添加换行 */
trailingNewline?: boolean;
}
/**
* 全局黑板 TypeScript 类型生成器
*
* 将全局黑板配置导出为 TypeScript 类型定义,提供:
* - 编译时类型检查
* - IDE 自动补全
* - 避免拼写错误
* - 重构友好
*/
export class GlobalBlackboardTypeGenerator {
/**
* 默认生成选项
*/
static readonly DEFAULT_OPTIONS: Required<TypeGenerationOptions> = {
constantCase: 'UPPER_SNAKE',
constantsName: 'GlobalVars',
interfaceName: 'GlobalBlackboardTypes',
typeAliasName: 'GlobalVariableName',
wrapperClassName: 'TypedGlobalBlackboard',
defaultsName: 'GlobalBlackboardDefaults',
importPath: '@esengine/behavior-tree',
includeConstants: true,
includeInterface: true,
includeTypeAlias: true,
includeWrapperClass: true,
includeDefaults: true,
customHeader: '',
quoteStyle: 'single',
trailingNewline: true
};
/**
* 生成 TypeScript 类型定义代码
*
* @param config 全局黑板配置
* @param options 生成选项
* @returns TypeScript 代码字符串
*
* @example
* ```typescript
* // 使用默认选项
* const code = GlobalBlackboardTypeGenerator.generate(config);
*
* // 自定义命名
* const code = GlobalBlackboardTypeGenerator.generate(config, {
* constantsName: 'GameVars',
* wrapperClassName: 'GameBlackboard'
* });
*
* // 只生成接口和类型别名,不生成包装类
* const code = GlobalBlackboardTypeGenerator.generate(config, {
* includeWrapperClass: false,
* includeDefaults: false
* });
* ```
*/
static generate(config: GlobalBlackboardConfig, options?: TypeGenerationOptions): string {
const opts = { ...this.DEFAULT_OPTIONS, ...options };
const now = new Date().toLocaleString('zh-CN', { hour12: false });
const variables = config.variables || [];
const parts: string[] = [];
// 生成文件头部注释
parts.push(this.generateHeader(now, opts));
// 根据配置生成各个部分
if (opts.includeConstants) {
parts.push(this.generateConstants(variables, opts));
}
if (opts.includeInterface) {
parts.push(this.generateInterface(variables, opts));
}
if (opts.includeTypeAlias) {
parts.push(this.generateTypeAliases(opts));
}
if (opts.includeWrapperClass) {
parts.push(this.generateTypedClass(opts));
}
if (opts.includeDefaults) {
parts.push(this.generateDefaults(variables, opts));
}
// 组合所有部分
let code = parts.join('\n\n');
// 添加文件末尾换行
if (opts.trailingNewline && !code.endsWith('\n')) {
code += '\n';
}
return code;
}
/**
* 生成文件头部注释
*/
private static generateHeader(timestamp: string, opts: Required<TypeGenerationOptions>): string {
const customHeader = opts.customHeader || `/**
* 全局黑板类型定义
*
* ⚠️ 此文件由编辑器自动生成,请勿手动修改!
* 生成时间: ${timestamp}
*/`;
return `${customHeader}
import { GlobalBlackboardService } from '${opts.importPath}';`;
}
/**
* 生成常量对象
*/
private static generateConstants(variables: any[], opts: Required<TypeGenerationOptions>): string {
const quote = opts.quoteStyle === 'single' ? "'" : '"';
if (variables.length === 0) {
return `/**
* 全局变量名称常量
*/
export const ${opts.constantsName} = {} as const;`;
}
// 按命名空间分组
const grouped = this.groupVariablesByNamespace(variables);
if (Object.keys(grouped).length === 1 && grouped[''] !== undefined) {
// 无命名空间,扁平结构
const entries = variables
.map((v) => ` ${this.transformName(v.name, opts.constantCase)}: ${quote}${v.name}${quote}`)
.join(',\n');
return `/**
* 全局变量名称常量
* 使用常量避免拼写错误
*/
export const ${opts.constantsName} = {
${entries}
} as const;`;
} else {
// 有命名空间,分组结构
const namespaces = Object.entries(grouped)
.map(([namespace, vars]) => {
if (namespace === '') {
// 根级别变量
return vars
.map((v) => ` ${this.transformName(v.name, opts.constantCase)}: ${quote}${v.name}${quote}`)
.join(',\n');
} else {
// 命名空间变量
const nsName = this.toPascalCase(namespace);
const entries = vars
.map((v) => {
const shortName = v.name.substring(namespace.length + 1);
return ` ${this.transformName(shortName, opts.constantCase)}: ${quote}${v.name}${quote}`;
})
.join(',\n');
return ` ${nsName}: {\n${entries}\n }`;
}
})
.join(',\n');
return `/**
* 全局变量名称常量
* 使用常量避免拼写错误
*/
export const ${opts.constantsName} = {
${namespaces}
} as const;`;
}
}
/**
* 生成接口定义
*/
private static generateInterface(variables: any[], opts: Required<TypeGenerationOptions>): string {
if (variables.length === 0) {
return `/**
* 全局变量类型定义
*/
export interface ${opts.interfaceName} {}`;
}
const properties = variables
.map((v) => {
const tsType = this.mapBlackboardTypeToTS(v.type);
const comment = v.description ? ` /** ${v.description} */\n` : '';
return `${comment} ${v.name}: ${tsType};`;
})
.join('\n');
return `/**
* 全局变量类型定义
*/
export interface ${opts.interfaceName} {
${properties}
}`;
}
/**
* 生成类型别名
*/
private static generateTypeAliases(opts: Required<TypeGenerationOptions>): string {
return `/**
* 全局变量名称联合类型
*/
export type ${opts.typeAliasName} = keyof ${opts.interfaceName};`;
}
/**
* 生成类型安全包装类
*/
private static generateTypedClass(opts: Required<TypeGenerationOptions>): string {
return `/**
* 类型安全的全局黑板服务包装器
*
* @example
* \`\`\`typescript
* // 游戏运行时使用
* const service = core.services.resolve(GlobalBlackboardService);
* const gb = new ${opts.wrapperClassName}(service);
*
* // 类型安全的获取
* const hp = gb.getValue('playerHP'); // 类型: number | undefined
*
* // 类型安全的设置
* gb.setValue('playerHP', 100); // ✅ 正确
* gb.setValue('playerHP', 'invalid'); // ❌ 编译错误
* \`\`\`
*/
export class ${opts.wrapperClassName} {
constructor(private service: GlobalBlackboardService) {}
/**
* 获取全局变量(类型安全)
*/
getValue<K extends ${opts.typeAliasName}>(
name: K
): ${opts.interfaceName}[K] | undefined {
return this.service.getValue(name);
}
/**
* 设置全局变量(类型安全)
*/
setValue<K extends ${opts.typeAliasName}>(
name: K,
value: ${opts.interfaceName}[K]
): boolean {
return this.service.setValue(name, value);
}
/**
* 检查全局变量是否存在
*/
hasVariable(name: ${opts.typeAliasName}): boolean {
return this.service.hasVariable(name);
}
/**
* 获取所有变量名
*/
getVariableNames(): ${opts.typeAliasName}[] {
return this.service.getVariableNames() as ${opts.typeAliasName}[];
}
}`;
}
/**
* 生成默认值配置
*/
private static generateDefaults(variables: any[], opts: Required<TypeGenerationOptions>): string {
if (variables.length === 0) {
return `/**
* 默认值配置
*/
export const ${opts.defaultsName}: ${opts.interfaceName} = {};`;
}
const properties = variables
.map((v) => {
const value = this.formatValue(v.value, v.type, opts);
return ` ${v.name}: ${value}`;
})
.join(',\n');
return `/**
* 默认值配置
*
* 可在游戏启动时用于初始化全局黑板
*
* @example
* \`\`\`typescript
* // 获取服务
* const service = core.services.resolve(GlobalBlackboardService);
*
* // 初始化配置
* const config = {
* version: '1.0',
* variables: Object.entries(${opts.defaultsName}).map(([name, value]) => ({
* name,
* type: typeof value as BlackboardValueType,
* value
* }))
* };
* service.importConfig(config);
* \`\`\`
*/
export const ${opts.defaultsName}: ${opts.interfaceName} = {
${properties}
};`;
}
/**
* 按命名空间分组变量
*/
private static groupVariablesByNamespace(variables: any[]): Record<string, any[]> {
const groups: Record<string, any[]> = { '': [] };
for (const variable of variables) {
const dotIndex = variable.name.indexOf('.');
if (dotIndex === -1) {
groups['']!.push(variable);
} else {
const namespace = variable.name.substring(0, dotIndex);
if (!groups[namespace]) {
groups[namespace] = [];
}
groups[namespace]!.push(variable);
}
}
return groups;
}
/**
* 将变量名转换为常量名UPPER_SNAKE_CASE
*/
private static toConstantName(name: string): string {
// player.hp -> PLAYER_HP
// playerHP -> PLAYER_HP
return name
.replace(/\./g, '_')
.replace(/([a-z])([A-Z])/g, '$1_$2')
.toUpperCase();
}
/**
* 转换为 PascalCase
*/
private static toPascalCase(str: string): string {
return str
.split(/[._-]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join('');
}
/**
* 映射黑板类型到 TypeScript 类型
*/
private static mapBlackboardTypeToTS(type: BlackboardValueType): string {
switch (type) {
case BlackboardValueType.Number:
return 'number';
case BlackboardValueType.String:
return 'string';
case BlackboardValueType.Boolean:
return 'boolean';
case BlackboardValueType.Vector2:
return '{ x: number; y: number }';
case BlackboardValueType.Vector3:
return '{ x: number; y: number; z: number }';
case BlackboardValueType.Object:
return 'any';
case BlackboardValueType.Array:
return 'any[]';
default:
return 'any';
}
}
/**
* 格式化值为 TypeScript 字面量
*/
private static formatValue(value: any, type: BlackboardValueType, opts: Required<TypeGenerationOptions>): string {
if (value === null || value === undefined) {
return 'undefined';
}
const quote = opts.quoteStyle === 'single' ? "'" : '"';
const escapeRegex = opts.quoteStyle === 'single' ? /'/g : /"/g;
const escapeChar = opts.quoteStyle === 'single' ? "\\'" : '\\"';
switch (type) {
case BlackboardValueType.String:
return `${quote}${value.toString().replace(escapeRegex, escapeChar)}${quote}`;
case BlackboardValueType.Number:
case BlackboardValueType.Boolean:
return String(value);
case BlackboardValueType.Vector2:
if (typeof value === 'object' && value.x !== undefined && value.y !== undefined) {
return `{ x: ${value.x}, y: ${value.y} }`;
}
return '{ x: 0, y: 0 }';
case BlackboardValueType.Vector3:
if (typeof value === 'object' && value.x !== undefined && value.y !== undefined && value.z !== undefined) {
return `{ x: ${value.x}, y: ${value.y}, z: ${value.z} }`;
}
return '{ x: 0, y: 0, z: 0 }';
case BlackboardValueType.Array:
return '[]';
case BlackboardValueType.Object:
return '{}';
default:
return 'undefined';
}
}
/**
* 根据指定的大小写风格转换变量名
*/
private static transformName(name: string, caseStyle: 'UPPER_SNAKE' | 'camelCase' | 'PascalCase'): string {
switch (caseStyle) {
case 'UPPER_SNAKE':
return this.toConstantName(name);
case 'camelCase':
return this.toCamelCase(name);
case 'PascalCase':
return this.toPascalCase(name);
default:
return name;
}
}
/**
* 转换为 camelCase
*/
private static toCamelCase(str: string): string {
const parts = str.split(/[._-]/);
if (parts.length === 0) return str;
return (parts[0] || '').toLowerCase() + parts.slice(1)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join('');
}
}

View File

@@ -1,290 +0,0 @@
/**
* 局部黑板变量信息
*/
export interface LocalBlackboardVariable {
name: string;
type: string;
value: any;
}
/**
* 局部黑板类型生成配置
*/
export interface LocalTypeGenerationOptions {
/** 行为树名称 */
behaviorTreeName: string;
/** 是否生成常量枚举 */
includeConstants?: boolean;
/** 是否生成默认值 */
includeDefaults?: boolean;
/** 是否生成辅助函数 */
includeHelpers?: boolean;
/** 使用单引号还是双引号 */
quoteStyle?: 'single' | 'double';
}
/**
* 局部黑板 TypeScript 类型生成器
*
* 为行为树的局部黑板变量生成类型安全的 TypeScript 定义
*/
export class LocalBlackboardTypeGenerator {
/**
* 生成局部黑板的 TypeScript 类型定义
*
* @param variables 黑板变量列表
* @param options 生成配置
* @returns TypeScript 代码
*/
static generate(
variables: Record<string, any>,
options: LocalTypeGenerationOptions
): string {
const opts = {
includeConstants: true,
includeDefaults: true,
includeHelpers: true,
quoteStyle: 'single' as const,
...options
};
const quote = opts.quoteStyle === 'single' ? "'" : '"';
const now = new Date().toLocaleString('zh-CN', { hour12: false });
const treeName = opts.behaviorTreeName;
const interfaceName = `${this.toPascalCase(treeName)}Blackboard`;
const constantsName = `${this.toPascalCase(treeName)}Vars`;
const defaultsName = `${this.toPascalCase(treeName)}Defaults`;
const parts: string[] = [];
// 文件头部注释
parts.push(`/**
* 行为树黑板变量类型定义
*
* 行为树: ${treeName}
* ⚠️ 此文件由编辑器自动生成,请勿手动修改!
* 生成时间: ${now}
*/`);
const varEntries = Object.entries(variables);
// 如果没有变量
if (varEntries.length === 0) {
parts.push(`\n/**
* 黑板变量类型定义(空)
*/
export interface ${interfaceName} {}`);
return parts.join('\n') + '\n';
}
// 生成常量枚举
if (opts.includeConstants) {
const constants = varEntries
.map(([name]) => ` ${this.toConstantName(name)}: ${quote}${name}${quote}`)
.join(',\n');
parts.push(`\n/**
* 黑板变量名称常量
* 使用常量避免拼写错误
*
* @example
* \`\`\`typescript
* // 使用常量代替字符串
* const hp = blackboard.getValue(${constantsName}.PLAYER_HP); // ✅ 类型安全
* const hp = blackboard.getValue('playerHP'); // ❌ 容易拼写错误
* \`\`\`
*/
export const ${constantsName} = {
${constants}
} as const;`);
}
// 生成类型接口
const interfaceProps = varEntries
.map(([name, value]) => {
const tsType = this.inferType(value);
return ` ${name}: ${tsType};`;
})
.join('\n');
parts.push(`\n/**
* 黑板变量类型定义
*/
export interface ${interfaceName} {
${interfaceProps}
}`);
// 生成变量名联合类型
parts.push(`\n/**
* 黑板变量名称联合类型
*/
export type ${this.toPascalCase(treeName)}VariableName = keyof ${interfaceName};`);
// 生成默认值
if (opts.includeDefaults) {
const defaultProps = varEntries
.map(([name, value]) => {
const formattedValue = this.formatValue(value, opts.quoteStyle);
return ` ${name}: ${formattedValue}`;
})
.join(',\n');
parts.push(`\n/**
* 黑板变量默认值
*
* 可用于初始化行为树黑板
*
* @example
* \`\`\`typescript
* // 创建行为树时使用默认值
* const blackboard = { ...${defaultsName} };
* const tree = new BehaviorTree(rootNode, blackboard);
* \`\`\`
*/
export const ${defaultsName}: ${interfaceName} = {
${defaultProps}
};`);
}
// 生成辅助函数
if (opts.includeHelpers) {
parts.push(`\n/**
* 创建类型安全的黑板访问器
*
* @example
* \`\`\`typescript
* const blackboard = create${this.toPascalCase(treeName)}Blackboard();
*
* // 类型安全的访问
* const hp = blackboard.playerHP; // 类型: number
* blackboard.playerHP = 100; // ✅ 正确
* blackboard.playerHP = 'invalid'; // ❌ 编译错误
* \`\`\`
*/
export function create${this.toPascalCase(treeName)}Blackboard(
initialValues?: Partial<${interfaceName}>
): ${interfaceName} {
return { ...${defaultsName}, ...initialValues };
}
/**
* 类型守卫:检查变量名是否有效
*/
export function is${this.toPascalCase(treeName)}Variable(
name: string
): name is ${this.toPascalCase(treeName)}VariableName {
return name in ${defaultsName};
}`);
}
return parts.join('\n') + '\n';
}
/**
* 推断 TypeScript 类型
*/
private static inferType(value: any): string {
if (value === null || value === undefined) {
return 'any';
}
const type = typeof value;
switch (type) {
case 'number':
return 'number';
case 'string':
return 'string';
case 'boolean':
return 'boolean';
case 'object':
if (Array.isArray(value)) {
if (value.length === 0) {
return 'any[]';
}
const elementType = this.inferType(value[0]);
return `${elementType}[]`;
}
// 检查是否是 Vector2 或 Vector3
if ('x' in value && 'y' in value) {
if ('z' in value) {
return '{ x: number; y: number; z: number }';
}
return '{ x: number; y: number }';
}
return 'any';
default:
return 'any';
}
}
/**
* 格式化值为 TypeScript 字面量
*/
private static formatValue(value: any, quoteStyle: 'single' | 'double'): string {
if (value === null) {
return 'null';
}
if (value === undefined) {
return 'undefined';
}
const quote = quoteStyle === 'single' ? "'" : '"';
const type = typeof value;
switch (type) {
case 'string':
const escaped = value
.replace(/\\/g, '\\\\')
.replace(quoteStyle === 'single' ? /'/g : /"/g,
quoteStyle === 'single' ? "\\'" : '\\"');
return `${quote}${escaped}${quote}`;
case 'number':
case 'boolean':
return String(value);
case 'object':
if (Array.isArray(value)) {
if (value.length === 0) {
return '[]';
}
const items = value.map((v) => this.formatValue(v, quoteStyle)).join(', ');
return `[${items}]`;
}
// Vector2/Vector3
if ('x' in value && 'y' in value) {
if ('z' in value) {
return `{ x: ${value.x}, y: ${value.y}, z: ${value.z} }`;
}
return `{ x: ${value.x}, y: ${value.y} }`;
}
// 普通对象
return '{}';
default:
return 'undefined';
}
}
/**
* 转换为 UPPER_SNAKE_CASE
*/
private static toConstantName(name: string): string {
return name
.replace(/\./g, '_')
.replace(/([a-z])([A-Z])/g, '$1_$2')
.toUpperCase();
}
/**
* 转换为 PascalCase
*/
private static toPascalCase(str: string): string {
return str
.split(/[._-]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join('');
}
}

View File

@@ -1,137 +0,0 @@
type EventHandler<T = any> = (data: T) => void;
interface Subscription {
unsubscribe: () => void;
}
export enum EditorEvent {
NODE_CREATED = 'node:created',
NODE_DELETED = 'node:deleted',
NODE_UPDATED = 'node:updated',
NODE_MOVED = 'node:moved',
NODE_SELECTED = 'node:selected',
CONNECTION_ADDED = 'connection:added',
CONNECTION_REMOVED = 'connection:removed',
EXECUTION_STARTED = 'execution:started',
EXECUTION_PAUSED = 'execution:paused',
EXECUTION_RESUMED = 'execution:resumed',
EXECUTION_STOPPED = 'execution:stopped',
EXECUTION_TICK = 'execution:tick',
EXECUTION_NODE_STATUS_CHANGED = 'execution:node_status_changed',
TREE_SAVED = 'tree:saved',
TREE_LOADED = 'tree:loaded',
TREE_VALIDATED = 'tree:validated',
BLACKBOARD_VARIABLE_UPDATED = 'blackboard:variable_updated',
BLACKBOARD_RESTORED = 'blackboard:restored',
CANVAS_ZOOM_CHANGED = 'canvas:zoom_changed',
CANVAS_PAN_CHANGED = 'canvas:pan_changed',
CANVAS_RESET = 'canvas:reset',
COMMAND_EXECUTED = 'command:executed',
COMMAND_UNDONE = 'command:undone',
COMMAND_REDONE = 'command:redone'
}
export class EditorEventBus {
private listeners: Map<string, Set<EventHandler>> = new Map();
private eventHistory: Array<{ event: string; data: any; timestamp: number }> = [];
private maxHistorySize: number = 100;
on<T = any>(event: EditorEvent | string, handler: EventHandler<T>): Subscription {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(handler);
return {
unsubscribe: () => this.off(event, handler)
};
}
once<T = any>(event: EditorEvent | string, handler: EventHandler<T>): Subscription {
const wrappedHandler = (data: T) => {
handler(data);
this.off(event, wrappedHandler);
};
return this.on(event, wrappedHandler);
}
off<T = any>(event: EditorEvent | string, handler: EventHandler<T>): void {
const handlers = this.listeners.get(event);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
this.listeners.delete(event);
}
}
}
emit<T = any>(event: EditorEvent | string, data?: T): void {
if (this.eventHistory.length >= this.maxHistorySize) {
this.eventHistory.shift();
}
this.eventHistory.push({
event,
data,
timestamp: Date.now()
});
const handlers = this.listeners.get(event);
if (handlers) {
handlers.forEach((handler) => {
try {
handler(data);
} catch (error) {
console.error(`Error in event handler for ${event}:`, error);
}
});
}
}
clear(event?: EditorEvent | string): void {
if (event) {
this.listeners.delete(event);
} else {
this.listeners.clear();
}
}
getListenerCount(event: EditorEvent | string): number {
return this.listeners.get(event)?.size || 0;
}
getAllEvents(): string[] {
return Array.from(this.listeners.keys());
}
getEventHistory(count?: number): Array<{ event: string; data: any; timestamp: number }> {
if (count) {
return this.eventHistory.slice(-count);
}
return [...this.eventHistory];
}
clearHistory(): void {
this.eventHistory = [];
}
}
let globalEventBus: EditorEventBus | null = null;
export function getGlobalEventBus(): EditorEventBus {
if (!globalEventBus) {
globalEventBus = new EditorEventBus();
}
return globalEventBus;
}
export function resetGlobalEventBus(): void {
globalEventBus = null;
}

View File

@@ -1,79 +0,0 @@
import { NodeTemplate, NodeTemplates } from '@esengine/behavior-tree';
import { Node } from '../../domain/models/Node';
import { Position } from '../../domain/value-objects/Position';
import { INodeFactory } from '../../domain/interfaces/INodeFactory';
/**
* 生成唯一ID
*/
function generateUniqueId(): string {
return `node-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* 节点工厂实现
*/
export class NodeFactory implements INodeFactory {
/**
* 创建节点
*/
createNode(
template: NodeTemplate,
position: Position,
data?: Record<string, unknown>
): Node {
const nodeId = generateUniqueId();
const nodeData = {
...template.defaultConfig,
...data
};
return new Node(nodeId, template, nodeData, position, []);
}
/**
* 根据模板类型创建节点
*/
createNodeByType(
nodeType: string,
position: Position,
data?: Record<string, unknown>
): Node {
const template = this.getTemplateByType(nodeType);
if (!template) {
throw new Error(`未找到节点模板: ${nodeType}`);
}
return this.createNode(template, position, data);
}
/**
* 克隆节点
*/
cloneNode(node: Node, newPosition?: Position): Node {
const position = newPosition || node.position;
const clonedId = generateUniqueId();
return new Node(
clonedId,
node.template,
node.data,
position,
[]
);
}
/**
* 根据类型获取模板
*/
private getTemplateByType(nodeType: string): NodeTemplate | null {
const allTemplates = NodeTemplates.getAllTemplates();
const template = allTemplates.find((t: NodeTemplate) => {
const defaultNodeType = t.defaultConfig.nodeType;
return defaultNodeType === nodeType;
});
return template || null;
}
}

View File

@@ -1 +0,0 @@
export { NodeFactory } from './NodeFactory';

View File

@@ -1,2 +0,0 @@
export * from './factories';
export * from './serialization';

View File

@@ -1,127 +0,0 @@
import { BehaviorTree } from '../../domain/models/BehaviorTree';
import { ISerializer, SerializationFormat } from '../../domain/interfaces/ISerializer';
import { BehaviorTreeAssetSerializer, EditorFormatConverter } from '@esengine/behavior-tree';
/**
* 序列化选项
*/
export interface SerializationOptions {
/**
* 资产版本号
*/
version?: string;
/**
* 资产名称
*/
name?: string;
/**
* 资产描述
*/
description?: string;
/**
* 创建时间
*/
createdAt?: string;
/**
* 修改时间
*/
modifiedAt?: string;
}
/**
* 行为树序列化器实现
*/
export class BehaviorTreeSerializer implements ISerializer {
private readonly defaultOptions: Required<SerializationOptions> = {
version: '1.0.0',
name: 'Untitled Behavior Tree',
description: '',
createdAt: new Date().toISOString(),
modifiedAt: new Date().toISOString()
};
constructor(private readonly options: SerializationOptions = {}) {
this.defaultOptions = { ...this.defaultOptions, ...options };
}
/**
* 序列化行为树
*/
serialize(tree: BehaviorTree, format: SerializationFormat): string | Uint8Array {
const treeObject = tree.toObject();
if (format === 'json') {
return JSON.stringify(treeObject, null, 2);
}
throw new Error(`不支持的序列化格式: ${format}`);
}
/**
* 反序列化行为树
*/
deserialize(data: string | Uint8Array, format: SerializationFormat): BehaviorTree {
if (format === 'json') {
if (typeof data !== 'string') {
throw new Error('JSON 格式需要字符串数据');
}
const obj = JSON.parse(data);
return BehaviorTree.fromObject(obj);
}
throw new Error(`不支持的反序列化格式: ${format}`);
}
/**
* 导出为运行时资产格式
* @param tree 行为树
* @param format 导出格式
* @param options 可选的序列化选项(覆盖默认值)
*/
exportToRuntimeAsset(
tree: BehaviorTree,
format: SerializationFormat,
options?: SerializationOptions
): string | Uint8Array {
const nodes = tree.nodes.map((node) => ({
id: node.id,
template: node.template,
data: node.data,
position: node.position.toObject(),
children: Array.from(node.children)
}));
const connections = tree.connections.map((conn) => conn.toObject());
const blackboard = tree.blackboard.toObject();
const finalOptions = { ...this.defaultOptions, ...options };
finalOptions.modifiedAt = new Date().toISOString();
const editorFormat = {
version: finalOptions.version,
metadata: {
name: finalOptions.name,
description: finalOptions.description,
createdAt: finalOptions.createdAt,
modifiedAt: finalOptions.modifiedAt
},
nodes,
connections,
blackboard
};
const asset = EditorFormatConverter.toAsset(editorFormat);
if (format === 'json') {
return BehaviorTreeAssetSerializer.serialize(asset, { format: 'json', pretty: true });
} else if (format === 'binary') {
return BehaviorTreeAssetSerializer.serialize(asset, { format: 'binary' });
}
throw new Error(`不支持的导出格式: ${format}`);
}
}

View File

@@ -1 +0,0 @@
export { BehaviorTreeSerializer } from './BehaviorTreeSerializer';

Some files were not shown because too many files have changed in this diff Show More