Files
esengine/packages/editor-app/src/App.tsx
YHH beaa1d09de feat: 预制体系统与架构改进 (#303)
* feat(prefab): 实现预制体系统和编辑器 UX 改进

## 预制体系统
- 新增 PrefabSerializer: 预制体序列化/反序列化
- 新增 PrefabInstanceComponent: 追踪预制体实例来源和修改
- 新增 PrefabService: 预制体核心服务
- 新增 PrefabLoader: 预制体资产加载器
- 新增预制体命令: Create/Instantiate/Apply/Revert/BreakLink

## 预制体编辑模式
- 支持双击 .prefab 文件进入编辑模式
- 预制体编辑模式工具栏 (保存/退出)
- 预制体实例指示器和操作菜单

## 编辑器 UX 改进
- SceneHierarchy 快捷键: F2 重命名, Ctrl+D 复制, ↑↓ 导航
- 支持双击实体名称内联编辑
- 删除实体时显示子节点数量警告
- 右键菜单添加重命名/复制选项及快捷键提示
- 布局持久化和重置功能

## Bug 修复
- 修复 editor-runtime 组件类重复导致的 TransformComponent 不识别问题
- 修复 .prefab-name 样式覆盖导致预制体工具栏文字不可见
- 修复 Inspector 资源字段高度不正确问题

* feat(editor): 改进编辑器 UX 交互体验

- ContentBrowser: 加载动画 spinner、搜索高亮、改进空状态设计
- SceneHierarchy: 选中项自动滚动到视图、搜索清除按钮
- PropertyInspector: 输入框本地状态管理、Enter/Escape 键处理
- EntityInspector: 组件折叠状态持久化、属性搜索清除按钮
- Viewport: 变换操作实时数值显示
- 国际化: 添加相关文本 (en/zh)

* fix(build): 修复 Web 构建资产加载和编辑器 UX 改进

构建系统修复:
- 修复 asset-catalog.json 字段名不匹配 (entries vs assets)
- 修复 BrowserFileSystemService 支持两种目录格式
- 修复 bundle 策略检测逻辑 (空对象判断)
- 修复 module.json 中 assetExtensions 声明和类型推断

行为树修复:
- 修复 BehaviorTreeExecutionSystem 使用 loadAsset 替代 loadAssetByPath
- 修复 BehaviorTreeAssetType 常量与 module.json 类型名一致 (behavior-tree)

编辑器 UX 改进:
- 构建完成对话框添加"打开文件夹"按钮
- 构建完成对话框样式优化 (圆形图标背景、按钮布局)
- SceneHierarchy 响应式布局 (窄窗口自动隐藏 Type 列)
- SceneHierarchy 隐藏滚动条

错误追踪:
- 添加全局错误处理器写入日志文件 (%TEMP%/esengine-editor-crash.log)
- 添加 append_to_log Tauri 命令

* feat(render): 修复 UI 渲染和点击特效系统

## UI 渲染修复
- 修复 GUID 验证 bug,使用统一的 isValidGUID() 函数
- 修复 UI 渲染顺序随机问题,Rust 端使用 IndexMap 替代 HashMap
- Web 运行时添加 assetPathResolver 支持 GUID 解析
- UIInteractableComponent.blockEvents 默认值改为 false

## 点击特效系统
- 新增 ClickFxComponent 和 ClickFxSystem
- 支持在点击位置播放粒子效果
- 支持多种触发模式和粒子轮换

## Camera 系统重构
- CameraSystem 从 ecs-engine-bindgen 移至 camera 包
- 新增 CameraManager 统一管理相机

## 编辑器改进
- 改进属性面板 UI 交互
- 粒子编辑器面板优化
- Transform 命令系统

* feat(render): 实现 Sorting Layer 系统和 Overlay 渲染层

- 新增 SortingLayerManager 管理排序层级 (Background, Default, Foreground, UI, Overlay)
- 实现 ISortable 接口,统一 Sprite、UI、Particle 的排序属性
- 修复粒子 Overlay 层被 UI 遮挡问题:添加独立的 Overlay Pass 在 UI 之后渲染
- 更新粒子资产格式:从 sortingOrder 改为 sortingLayer + orderInLayer
- 更新粒子编辑器面板支持新的排序属性
- 优化 UI 渲染系统使用新的排序层级

* feat(ci): 集成 SignPath 代码签名服务

- 添加 SignPath 自动签名工作流(Windows)
- 配置 release-editor.yml 支持代码签名
- 将构建改为草稿模式,等待签名完成后发布
- 添加证书文件到 .gitignore 防止泄露

* fix(asset): 修复 Web 构建资产路径解析和全局单例移除

## 资产路径修复
- 修复 Tauri 本地服务器 `/asset?path=...` 路径解析,正确与 root 目录连接
- BrowserPathResolver 支持两种模式:
  - 'proxy': 使用 /asset?path=... 格式(编辑器 Run in Browser)
  - 'direct': 使用直接路径 /assets/path.png(独立 Web 构建)
- BrowserRuntime 使用 'direct' 模式,无需 Tauri 代理

## 架构改进 - 移除全局单例
- 移除 globalAssetManager 导出,改用 AssetManagerToken 依赖注入
- 移除 globalPathResolver 导出,改用 PathResolutionService
- 移除 globalPathResolutionService 导出
- ParticleUpdateSystem/ClickFxSystem 通过 setAssetManager() 注入依赖
- EngineService 使用 new AssetManager() 替代全局实例

## 新增服务
- PathResolutionService: 统一路径解析接口
- RuntimeModeService: 运行时模式查询服务
- SerializationContext: EntityRef 序列化上下文

## 其他改进
- 完善 ServiceToken 注释说明本地定义的意图
- 导出 BrowserPathResolveMode 类型

* fix(build): 添加 world-streaming composite 设置修复类型检查

* fix(build): 移除 world-streaming 引用避免 composite 冲突

* fix(build): 将 const enum 改为 enum 兼容 isolatedModules

* fix(build): 添加缺失的 IAssetManager 导入
2025-12-13 19:44:08 +08:00

1268 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as ReactDOM from 'react-dom';
import * as ReactJSXRuntime from 'react/jsx-runtime';
import { Core, createLogger, Scene } from '@esengine/ecs-framework';
import * as ECSFramework from '@esengine/ecs-framework';
import { getProfilerService } from './services/getService';
// 将 React 暴露到全局,供动态加载的插件使用
// editor-runtime.js 将 React 设为 external需要从全局获取
(window as any).React = React;
(window as any).ReactDOM = ReactDOM;
(window as any).ReactJSXRuntime = ReactJSXRuntime;
import {
PluginManager,
UIRegistry,
MessageHub,
EntityStoreService,
ComponentRegistry,
LocaleService,
LogService,
SettingsRegistry,
SceneManagerService,
ProjectService,
CompilerRegistry,
ICompilerRegistry,
InspectorRegistry,
INotification,
CommandManager,
BuildService
} from '@esengine/editor-core';
import type { IDialogExtended } from './services/TauriDialogService';
import { GlobalBlackboardService } from '@esengine/behavior-tree';
import { ServiceRegistry, PluginInstaller, useDialogStore } from './app/managers';
import { useEditorStore } from './stores';
import { StartupPage } from './components/StartupPage';
import { ProjectCreationWizard } from './components/ProjectCreationWizard';
import { SceneHierarchy } from './components/SceneHierarchy';
import { ContentBrowser } from './components/ContentBrowser';
import { Inspector } from './components/inspectors/Inspector';
import { AssetBrowser } from './components/AssetBrowser';
import { Viewport } from './components/Viewport';
import { AdvancedProfilerWindow } from './components/AdvancedProfilerWindow';
import { PortManager } from './components/PortManager';
import { SettingsWindow } from './components/SettingsWindow';
import { AboutDialog } from './components/AboutDialog';
import { ErrorDialog } from './components/ErrorDialog';
import { ConfirmDialog } from './components/ConfirmDialog';
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
import { BuildSettingsWindow } from './components/BuildSettingsWindow';
import { ForumPanel } from './components/forum';
import { ToastProvider, useToast } from './components/Toast';
import { TitleBar } from './components/TitleBar';
import { MainToolbar } from './components/MainToolbar';
import { FlexLayoutDockContainer, FlexDockPanel, type FlexLayoutDockContainerHandle } from './components/FlexLayoutDockContainer';
import { StatusBar } from './components/StatusBar';
import { TauriAPI } from './api/tauri';
import { SettingsService } from './services/SettingsService';
import { PluginLoader } from './services/PluginLoader';
import { EngineService } from './services/EngineService';
import { CompilerConfigDialog } from './components/CompilerConfigDialog';
import { checkForUpdatesOnStartup } from './utils/updater';
import { useLocale } from './hooks/useLocale';
import { useStoreSubscriptions } from './hooks/useStoreSubscriptions';
import { en, zh, es } from './locales';
import type { Locale } from '@esengine/editor-core';
import { Loader2 } from 'lucide-react';
import './styles/App.css';
const coreInstance = Core.create({ debug: true });
const localeService = new LocaleService();
localeService.registerTranslations('en', en);
localeService.registerTranslations('zh', zh);
localeService.registerTranslations('es', es);
Core.services.registerInstance(LocaleService, localeService);
Core.services.registerSingleton(GlobalBlackboardService);
Core.services.registerSingleton(CompilerRegistry);
// 在 CompilerRegistry 实例化后,也用 Symbol 注册,用于跨包插件访问
// 注意registerSingleton 会延迟实例化,所以需要在第一次使用后再注册 Symbol
const compilerRegistryInstance = Core.services.resolve(CompilerRegistry);
Core.services.registerInstance(ICompilerRegistry, compilerRegistryInstance);
const logger = createLogger('App');
function App() {
const initRef = useRef(false);
const layoutContainerRef = useRef<FlexLayoutDockContainerHandle>(null);
const [pluginLoader] = useState(() => new PluginLoader());
const { showToast, hideToast } = useToast();
// ===== 本地初始化状态(只用于初始化阶段)| Local init state (only for initialization) =====
const [initialized, setInitialized] = useState(false);
// ===== 从 EditorStore 获取状态 | Get state from EditorStore =====
const {
projectLoaded, setProjectLoaded,
currentProjectPath, setCurrentProjectPath,
availableScenes, setAvailableScenes,
isLoading, setIsLoading,
loadingMessage,
panels, setPanels,
activeDynamicPanels, addDynamicPanel, removeDynamicPanel, clearDynamicPanels,
dynamicPanelTitles, setDynamicPanelTitle,
activePanelId, setActivePanelId,
pluginUpdateTrigger, triggerPluginUpdate,
isRemoteConnected, setIsRemoteConnected,
isContentBrowserDocked, setIsContentBrowserDocked,
isEditorFullscreen, setIsEditorFullscreen,
status, setStatus,
showProjectWizard, setShowProjectWizard,
settingsInitialCategory, setSettingsInitialCategory,
compilerDialog, openCompilerDialog, closeCompilerDialog,
} = useEditorStore();
// ===== 服务实例用 useRef不触发重渲染| Service instances use useRef (no re-renders) =====
const pluginManagerRef = useRef<PluginManager | null>(null);
const entityStoreRef = useRef<EntityStoreService | null>(null);
const messageHubRef = useRef<MessageHub | null>(null);
const inspectorRegistryRef = useRef<InspectorRegistry | null>(null);
const logServiceRef = useRef<LogService | null>(null);
const uiRegistryRef = useRef<UIRegistry | null>(null);
const settingsRegistryRef = useRef<SettingsRegistry | null>(null);
const sceneManagerRef = useRef<SceneManagerService | null>(null);
const notificationRef = useRef<INotification | null>(null);
const dialogRef = useRef<IDialogExtended | null>(null);
const buildServiceRef = useRef<BuildService | null>(null);
const projectServiceRef = useRef<ProjectService | null>(null);
// 兼容层:提供 getter 访问服务 | Compatibility layer: provide getter access to services
const pluginManager = pluginManagerRef.current;
const entityStore = entityStoreRef.current;
const messageHub = messageHubRef.current;
const inspectorRegistry = inspectorRegistryRef.current;
const logService = logServiceRef.current;
const uiRegistry = uiRegistryRef.current;
const settingsRegistry = settingsRegistryRef.current;
const sceneManager = sceneManagerRef.current;
const notification = notificationRef.current;
const dialog = dialogRef.current;
const buildService = buildServiceRef.current;
const projectServiceState = projectServiceRef.current;
const [commandManager] = useState(() => new CommandManager());
const { t, locale, changeLocale } = useLocale();
// 初始化 Store 订阅(集中管理 MessageHub 订阅)
// Initialize store subscriptions (centrally manage MessageHub subscriptions)
useStoreSubscriptions({
messageHub: messageHubRef.current,
entityStore: entityStoreRef.current,
sceneManager: sceneManagerRef.current,
enabled: initialized,
});
// 同步 locale 到 TauriDialogService
useEffect(() => {
if (dialogRef.current) {
dialogRef.current.setLocale(locale);
}
}, [locale]);
// ===== 从 DialogStore 获取对话框状态 | Get dialog state from DialogStore =====
const {
showProfiler, setShowProfiler,
showAdvancedProfiler, setShowAdvancedProfiler,
showPortManager, setShowPortManager,
showSettings, setShowSettings,
showAbout, setShowAbout,
showPluginGenerator, setShowPluginGenerator,
showBuildSettings, setShowBuildSettings,
errorDialog, setErrorDialog,
confirmDialog, setConfirmDialog
} = useDialogStore();
useEffect(() => {
// 禁用默认右键菜单
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
};
document.addEventListener('contextmenu', handleContextMenu);
return () => {
document.removeEventListener('contextmenu', handleContextMenu);
};
}, []);
// Global keyboard shortcuts for undo/redo | 全局撤销/重做快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Skip if user is typing in an input | 如果用户正在输入则跳过
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
// Ctrl+Z: Undo | 撤销
if (e.ctrlKey && !e.shiftKey && e.key === 'z') {
e.preventDefault();
if (commandManager.canUndo()) {
commandManager.undo();
}
}
// Ctrl+Y or Ctrl+Shift+Z: Redo | 重做
else if ((e.ctrlKey && e.key === 'y') || (e.ctrlKey && e.shiftKey && e.key === 'z')) {
e.preventDefault();
if (commandManager.canRedo()) {
commandManager.redo();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [commandManager]);
// 快捷键监听
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey) {
switch (e.key.toLowerCase()) {
case 's': {
// Skip if any modal/dialog is open
// 如果有模态窗口/对话框打开则跳过
const hasModalOpen = showBuildSettings || showSettings || showAbout ||
showPluginGenerator || showPortManager || showAdvancedProfiler ||
errorDialog || confirmDialog;
if (hasModalOpen) {
return;
}
// Skip if focus is in an input/textarea/contenteditable element
// 如果焦点在输入框/文本域/可编辑元素中则跳过
const activeEl = document.activeElement;
const isInInput = activeEl instanceof HTMLInputElement ||
activeEl instanceof HTMLTextAreaElement ||
activeEl?.getAttribute('contenteditable') === 'true';
if (isInInput) {
return;
}
e.preventDefault();
if (sceneManager) {
try {
// 检查是否在预制体编辑模式 | Check if in prefab edit mode
if (sceneManager.isPrefabEditMode()) {
await sceneManager.savePrefab();
const prefabState = sceneManager.getPrefabEditModeState();
showToast(t('editMode.prefab.savedSuccess', { name: prefabState?.prefabName ?? 'Prefab' }), 'success');
} else {
await sceneManager.saveScene();
const sceneState = sceneManager.getSceneState();
showToast(t('scene.savedSuccess', { name: sceneState.sceneName }), 'success');
}
} catch (error) {
console.error('Failed to save:', error);
if (sceneManager.isPrefabEditMode()) {
showToast(t('editMode.prefab.saveFailed'), 'error');
} else {
showToast(t('scene.saveFailed'), 'error');
}
}
}
break;
}
case 'r':
e.preventDefault();
handleReloadPlugins();
break;
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [sceneManager, locale, currentProjectPath, pluginManager,
showBuildSettings, showSettings, showAbout, showPluginGenerator,
showPortManager, showAdvancedProfiler, errorDialog, confirmDialog]);
// 插件和通知订阅 | Plugin and notification subscriptions
useEffect(() => {
if (!initialized || !messageHubRef.current) return;
const hub = messageHubRef.current;
const unsubscribeEnabled = hub.subscribe('plugin:enabled', () => {
triggerPluginUpdate();
});
const unsubscribeDisabled = hub.subscribe('plugin:disabled', () => {
triggerPluginUpdate();
});
const unsubscribeNotification = hub.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();
};
}, [initialized, triggerPluginUpdate, showToast]);
// 监听远程连接状态
// Monitor remote connection status
useEffect(() => {
const checkConnection = () => {
const profilerService = getProfilerService();
const connected = !!(profilerService && profilerService.isConnected());
setIsRemoteConnected((prevConnected) => {
if (connected !== prevConnected) {
// 状态发生变化 | State has changed
if (connected) {
setStatus(t('header.status.remoteConnected'));
} else {
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;
});
};
const interval = setInterval(checkConnection, 1000);
return () => clearInterval(interval);
}, [projectLoaded, t]);
useEffect(() => {
const initializeEditor = async () => {
// 使用 ref 防止 React StrictMode 的双重调用
if (initRef.current) {
return;
}
initRef.current = true;
try {
// ECS Framework 已通过 PluginSDKRegistry 暴露到全局
// ECS Framework is exposed globally via PluginSDKRegistry
const editorScene = new Scene();
Core.setScene(editorScene);
const serviceRegistry = new ServiceRegistry();
const services = serviceRegistry.registerAllServices(coreInstance);
serviceRegistry.setupRemoteLogListener(services.logService);
const pluginInstaller = new PluginInstaller();
await pluginInstaller.installBuiltinPlugins(services.pluginManager);
// 初始化编辑器模块(安装设置、面板等)
await services.pluginManager.initializeEditor(Core.services);
services.notification.setCallbacks(showToast, hideToast);
(services.dialog as IDialogExtended).setConfirmCallback(setConfirmDialog);
services.messageHub.subscribe('ui:openWindow', (data: any) => {
const { windowId } = data;
if (windowId === 'profiler') {
setShowProfiler(true);
} else if (windowId === 'advancedProfiler') {
setShowAdvancedProfiler(true);
} else if (windowId === 'pluginManager') {
// 插件管理现在整合到设置窗口中
setSettingsInitialCategory('plugins');
setShowSettings(true);
}
});
// 设置服务引用(不触发重渲染)| Set service refs (no re-renders)
pluginManagerRef.current = services.pluginManager;
entityStoreRef.current = services.entityStore;
messageHubRef.current = services.messageHub;
inspectorRegistryRef.current = services.inspectorRegistry;
logServiceRef.current = services.logService;
uiRegistryRef.current = services.uiRegistry;
settingsRegistryRef.current = services.settingsRegistry;
sceneManagerRef.current = services.sceneManager;
notificationRef.current = services.notification;
dialogRef.current = services.dialog as IDialogExtended;
buildServiceRef.current = services.buildService;
// 设置初始化完成(触发一次重渲染)| Set initialized (triggers one re-render)
setInitialized(true);
setStatus(t('header.status.ready'));
// Check for updates on startup (after 3 seconds)
checkForUpdatesOnStartup();
} catch (error) {
console.error('Failed to initialize editor:', error);
setStatus(t('header.status.failed'));
}
};
initializeEditor();
}, []);
// 初始化后订阅消息 | Subscribe to messages after initialization
useEffect(() => {
if (!initialized || !messageHubRef.current) return;
const hub = messageHubRef.current;
const unsubscribe = hub.subscribe('dynamic-panel:open', (data: any) => {
const { panelId, title } = data;
logger.info('Opening dynamic panel:', panelId, 'with title:', title);
addDynamicPanel(panelId, title);
setActivePanelId(panelId);
});
return () => unsubscribe?.();
}, [initialized, addDynamicPanel, setActivePanelId]);
useEffect(() => {
if (!initialized || !messageHubRef.current) return;
const hub = messageHubRef.current;
const unsubscribe = hub.subscribe('editor:fullscreen', (data: any) => {
const { fullscreen } = data;
logger.info('Editor fullscreen state changed:', fullscreen);
setIsEditorFullscreen(fullscreen);
});
return () => unsubscribe?.();
}, [initialized, setIsEditorFullscreen]);
useEffect(() => {
if (!initialized || !messageHubRef.current) return;
const hub = messageHubRef.current;
const unsubscribe = hub.subscribe('compiler:open-dialog', (data: {
compilerId: string;
currentFileName?: string;
projectPath?: string;
}) => {
logger.info('Opening compiler dialog:', data.compilerId);
openCompilerDialog(data.compilerId, data.currentFileName);
});
return () => unsubscribe?.();
}, [initialized, openCompilerDialog]);
// 注册引擎快照请求处理器(用于预制体编辑模式)
// Register engine snapshot request handlers (for prefab edit mode)
useEffect(() => {
if (!initialized || !messageHubRef.current) return;
const hub = messageHubRef.current;
const unsubscribeSave = hub.onRequest<void, boolean>(
'engine:saveSceneSnapshot',
async () => {
const engineService = EngineService.getInstance();
return engineService.saveSceneSnapshot();
}
);
const unsubscribeRestore = hub.onRequest<void, boolean>(
'engine:restoreSceneSnapshot',
async () => {
const engineService = EngineService.getInstance();
return await engineService.restoreSceneSnapshot();
}
);
return () => {
unsubscribeSave?.();
unsubscribeRestore?.();
};
}, [initialized]);
const handleOpenRecentProject = async (projectPath: string) => {
try {
setIsLoading(true, t('loading.step1'));
const projectService = Core.services.resolve(ProjectService);
if (!projectService) {
console.error('Required services not available');
setIsLoading(false);
return;
}
projectServiceRef.current = projectService;
await projectService.openProject(projectPath);
// 注意:插件配置会在引擎初始化后加载和激活
// Note: Plugin config will be loaded and activated after engine initialization
// 设置 Tauri project:// 协议的基础路径(用于加载插件等项目文件)
await TauriAPI.setProjectBasePath(projectPath);
// 更新项目 tsconfig直接引用引擎类型定义
// Update project tsconfig to reference engine type definitions directly
try {
await TauriAPI.updateProjectTsconfig(projectPath);
} catch (e) {
console.warn('[App] Failed to update project tsconfig:', e);
}
const settings = SettingsService.getInstance();
settings.addRecentProject(projectPath);
setCurrentProjectPath(projectPath);
// Scan for available scenes in project
// 扫描项目中可用的场景
try {
const sceneFiles = await TauriAPI.scanDirectory(`${projectPath}/scenes`, '*.ecs');
const sceneNames = sceneFiles.map(f => `scenes/${f.split(/[\\/]/).pop()}`);
setAvailableScenes(sceneNames);
console.log('[App] Found scenes:', sceneNames);
} catch (e) {
console.warn('[App] Failed to scan scenes:', e);
}
// 设置 projectLoaded 为 true触发主界面渲染包括 Viewport
setProjectLoaded(true);
// 等待引擎初始化完成Viewport 渲染后会触发引擎初始化)
setIsLoading(true, t('loading.step2'));
const engineService = EngineService.getInstance();
// 等待引擎初始化(最多等待 30 秒,因为需要等待 Viewport 渲染)
const engineReady = await engineService.waitForInitialization(30000);
if (!engineReady) {
throw new Error(t('loading.engineTimeoutError'));
}
// 加载项目插件配置并激活插件(在引擎初始化后、模块系统初始化前)
// Load project plugin config and activate plugins (after engine init, before module system init)
if (pluginManagerRef.current) {
const pluginSettings = projectService.getPluginSettings();
console.log('[App] Plugin settings from project:', pluginSettings);
if (pluginSettings && pluginSettings.enabledPlugins.length > 0) {
console.log('[App] Loading plugin config:', pluginSettings.enabledPlugins);
await pluginManagerRef.current.loadConfig({ enabledPlugins: pluginSettings.enabledPlugins });
} else {
console.log('[App] No plugin settings found in project config');
}
}
// 初始化模块系统(所有插件的 runtimeModule 会在 PluginManager 安装时自动注册)
await engineService.initializeModuleSystems();
// 应用项目的 UI 设计分辨率
// Apply project's UI design resolution
const uiResolution = projectService.getUIDesignResolution();
engineService.setUICanvasSize(uiResolution.width, uiResolution.height);
setStatus(t('header.status.projectOpened'));
setIsLoading(true, t('loading.step3'));
const sceneManagerService = Core.services.resolve(SceneManagerService);
if (sceneManagerService) {
await sceneManagerService.newScene();
}
if (pluginManagerRef.current) {
setIsLoading(true, t('loading.loadingPlugins'));
await pluginLoader.loadProjectPlugins(projectPath, pluginManagerRef.current);
}
setIsLoading(false);
} catch (error) {
console.error('Failed to open project:', error);
setStatus(t('header.status.failed'));
setIsLoading(false);
const errorMessage = error instanceof Error ? error.message : String(error);
setErrorDialog({
title: t('project.openFailed'),
message: `${t('project.openFailed')}:\n${errorMessage}`
});
}
};
const handleOpenProject = async () => {
try {
const projectPath = await TauriAPI.openProjectDialog();
if (!projectPath) return;
await handleOpenRecentProject(projectPath);
} catch (error) {
console.error('Failed to open project dialog:', error);
}
};
const handleCreateProject = () => {
setShowProjectWizard(true);
};
const handleCreateProjectFromWizard = async (projectName: string, projectPath: string, _templateId: string) => {
// 使用与 projectPath 相同的路径分隔符 | Use same separator as projectPath
const sep = projectPath.includes('/') ? '/' : '\\';
const fullProjectPath = `${projectPath}${sep}${projectName}`;
try {
setIsLoading(true, t('project.creating'));
const projectService = Core.services.resolve(ProjectService);
if (!projectService) {
console.error('ProjectService not available');
setIsLoading(false);
setErrorDialog({
title: t('project.createFailed'),
message: t('project.serviceUnavailable')
});
return;
}
await projectService.createProject(fullProjectPath);
setIsLoading(true, t('project.createdOpening'));
await handleOpenRecentProject(fullProjectPath);
} catch (error) {
console.error('Failed to create project:', error);
setIsLoading(false);
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('already exists')) {
setConfirmDialog({
title: t('project.alreadyExists'),
message: t('project.existsQuestion'),
confirmText: t('project.open'),
cancelText: t('common.cancel'),
onConfirm: () => {
setConfirmDialog(null);
setIsLoading(true, t('project.opening'));
handleOpenRecentProject(fullProjectPath).catch((err) => {
console.error('Failed to open project:', err);
setIsLoading(false);
setErrorDialog({
title: t('project.openFailed'),
message: `${t('project.openFailed')}:\n${err instanceof Error ? err.message : String(err)}`
});
});
}
});
} else {
setStatus(t('project.createFailed'));
setErrorDialog({
title: t('project.createFailed'),
message: `${t('project.createFailed')}:\n${errorMessage}`
});
}
}
};
const handleBrowseProjectPath = async (): Promise<string | null> => {
try {
const path = await TauriAPI.openProjectDialog();
return path || null;
} catch (error) {
console.error('Failed to browse path:', error);
return null;
}
};
const handleNewScene = async () => {
if (!sceneManager) {
console.error('SceneManagerService not available');
return;
}
try {
await sceneManager.newScene();
setStatus(t('scene.newCreated'));
} catch (error) {
console.error('Failed to create new scene:', error);
setStatus(t('scene.createFailed'));
}
};
const handleOpenScene = async () => {
if (!sceneManager) {
console.error('SceneManagerService not available');
return;
}
try {
await sceneManager.openScene();
const sceneState = sceneManager.getSceneState();
setStatus(t('scene.openedSuccess', { name: sceneState.sceneName }));
} catch (error) {
console.error('Failed to open scene:', error);
setStatus(t('scene.openFailed'));
}
};
const handleOpenSceneByPath = useCallback(async (scenePath: string) => {
if (!sceneManager) {
console.error('SceneManagerService not available');
return;
}
try {
await sceneManager.openScene(scenePath);
const sceneState = sceneManager.getSceneState();
setStatus(t('scene.openedSuccess', { name: sceneState.sceneName }));
} catch (error) {
console.error('Failed to open scene:', error);
setStatus(t('scene.openFailed'));
setErrorDialog({
title: t('scene.openFailed'),
message: `${t('scene.openFailed')}:\n${error instanceof Error ? error.message : String(error)}`
});
}
}, [sceneManager, locale]);
const handleSaveScene = async () => {
if (!sceneManager) {
console.error('SceneManagerService not available');
return;
}
try {
await sceneManager.saveScene();
const sceneState = sceneManager.getSceneState();
setStatus(t('scene.savedSuccess', { name: sceneState.sceneName }));
} catch (error) {
console.error('Failed to save scene:', error);
setStatus(t('scene.saveFailed'));
}
};
const handleSaveSceneAs = async () => {
if (!sceneManager) {
console.error('SceneManagerService not available');
return;
}
try {
await sceneManager.saveSceneAs();
const sceneState = sceneManager.getSceneState();
setStatus(t('scene.savedSuccess', { name: sceneState.sceneName }));
} catch (error) {
console.error('Failed to save scene as:', error);
setStatus(t('scene.saveAsFailed'));
}
};
const handleCloseProject = async () => {
// 卸载项目插件
if (pluginManager) {
await pluginLoader.unloadProjectPlugins(pluginManager);
}
// 清理场景(会清理所有实体和系统)
// Clear scene (clears all entities and systems)
const scene = Core.scene;
if (scene) {
scene.end();
}
// 清理模块系统
const engineService = EngineService.getInstance();
engineService.clearModuleSystems();
// 关闭 ProjectService 中的项目
const projectService = Core.services.tryResolve(ProjectService);
if (projectService) {
await projectService.closeProject();
}
setProjectLoaded(false);
setCurrentProjectPath(null);
setStatus(t('header.status.ready'));
};
const handleExit = () => {
window.close();
};
const handleLocaleChange = (newLocale: Locale) => {
changeLocale(newLocale);
// 通知所有已加载的插件更新语言 | Notify all loaded plugins to update locale
if (pluginManagerRef.current) {
pluginManagerRef.current.setLocale(newLocale);
// 通过 MessageHub 通知需要重新获取节点模板 | Notify via MessageHub to refetch node templates
if (messageHubRef.current) {
messageHubRef.current.publish('locale:changed', { locale: newLocale });
}
}
};
const handleToggleDevtools = async () => {
try {
await TauriAPI.toggleDevtools();
} catch (error) {
console.error('Failed to toggle devtools:', error);
}
};
const handleOpenAbout = () => {
setShowAbout(true);
};
const handleCreatePlugin = () => {
setShowPluginGenerator(true);
};
const handleReloadPlugins = async () => {
if (currentProjectPath && pluginManagerRef.current) {
try {
// 1. 关闭所有动态面板 | Close all dynamic panels
clearDynamicPanels();
// 2. 清空当前面板列表(强制卸载插件面板组件)| Clear panel list (force unmount plugin panels)
setPanels((prev) => prev.filter((p) =>
['scene-hierarchy', 'inspector', 'console', 'asset-browser'].includes(p.id)
));
// 3. 等待React完成卸载 | Wait for React to unmount
await new Promise((resolve) => setTimeout(resolve, 200));
// 4. 卸载所有项目插件清理UIRegistry、调用uninstall| Unload all project plugins
await pluginLoader.unloadProjectPlugins(pluginManagerRef.current);
// 5. 等待卸载完成 | Wait for unload
await new Promise((resolve) => setTimeout(resolve, 100));
// 6. 重新加载插件 | Reload plugins
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManagerRef.current);
// 7. 触发面板重新渲染 | Trigger panel re-render
triggerPluginUpdate();
showToast(t('plugin.reloadedSuccess'), 'success');
} catch (error) {
console.error('Failed to reload plugins:', error);
showToast(t('plugin.reloadFailed'), 'error');
}
}
};
// ===== 面板构建(拆分依赖减少重建)| Panel building (split deps to reduce rebuilds) =====
// 使用 ref 存储面板构建函数,避免频繁重建
// Use ref to store panel builder function to avoid frequent rebuilds
const buildPanelsRef = useRef<() => void>(() => {});
// 更新面板构建函数(不触发重渲染)| Update panel builder (no re-render)
buildPanelsRef.current = () => {
if (!projectLoaded || !initialized) return;
const hub = messageHubRef.current;
const store = entityStoreRef.current;
const registry = uiRegistryRef.current;
const inspReg = inspectorRegistryRef.current;
if (!hub || !store || !registry) return;
const corePanels: FlexDockPanel[] = [
{
id: 'scene-hierarchy',
title: t('panel.sceneHierarchy'),
content: <SceneHierarchy entityStore={store} messageHub={hub} commandManager={commandManager} />,
closable: false,
layout: { position: 'right-top' }
},
{
id: 'viewport',
title: t('panel.viewport'),
content: <Viewport locale={locale} messageHub={hub} commandManager={commandManager} />,
closable: false,
layout: { position: 'center' }
},
{
id: 'inspector',
title: t('panel.inspector'),
content: <Inspector entityStore={store} messageHub={hub} inspectorRegistry={inspReg!} projectPath={currentProjectPath} commandManager={commandManager} />,
closable: false,
layout: { position: 'right-bottom' }
},
{
id: 'forum',
title: t('panel.forum'),
content: <ForumPanel />,
closable: true,
layout: { position: 'center' }
}
];
// 如果内容管理器已停靠,添加到面板 | If content browser is docked, add to panels
if (isContentBrowserDocked) {
corePanels.push({
id: 'content-browser',
title: t('panel.contentBrowser'),
content: (
<ContentBrowser
projectPath={currentProjectPath}
locale={locale}
onOpenScene={handleOpenSceneByPath}
isDrawer={false}
onDockInLayout={() => setIsContentBrowserDocked(false)}
/>
),
closable: true,
layout: { position: 'bottom', weight: 20, requiresSeparateTabset: true }
});
}
// 获取启用的插件面板 | Get enabled plugin panels
const pluginPanels: FlexDockPanel[] = registry.getAllPanels()
.filter((panelDesc) => panelDesc.component && !panelDesc.isDynamic)
.map((panelDesc) => {
const Component = panelDesc.component!;
const title = panelDesc.titleKey ? t(panelDesc.titleKey) : panelDesc.title;
return {
id: panelDesc.id,
title,
content: <Component key={`${panelDesc.id}-${pluginUpdateTrigger}`} projectPath={currentProjectPath} />,
closable: panelDesc.closable ?? true
};
});
// 添加激活的动态面板 | Add active dynamic panels
const dynamicPanels: FlexDockPanel[] = activeDynamicPanels
.filter((panelId) => {
const panelDesc = registry.getPanel(panelId);
return panelDesc && (panelDesc.component || panelDesc.render);
})
.map((panelId) => {
const panelDesc = registry.getPanel(panelId)!;
const customTitle = dynamicPanelTitles.get(panelId);
const defaultTitle = panelDesc.titleKey ? t(panelDesc.titleKey) : panelDesc.title;
let content: React.ReactNode;
if (panelDesc.component) {
const Component = panelDesc.component;
content = <Component projectPath={currentProjectPath} locale={locale} />;
} else if (panelDesc.render) {
content = panelDesc.render();
}
return {
id: panelDesc.id,
title: customTitle || defaultTitle,
content,
closable: panelDesc.closable ?? true
};
});
setPanels([...corePanels, ...pluginPanels, ...dynamicPanels]);
};
// Effect 1: 项目加载后首次构建面板 | Build panels after project loads
useEffect(() => {
if (projectLoaded && initialized) {
buildPanelsRef.current();
}
}, [projectLoaded, initialized]);
// Effect 2: 插件更新时重建 | Rebuild on plugin update
useEffect(() => {
if (projectLoaded && initialized && pluginUpdateTrigger > 0) {
buildPanelsRef.current();
}
}, [projectLoaded, initialized, pluginUpdateTrigger]);
// Effect 3: 动态面板变化时重建 | Rebuild on dynamic panel change
useEffect(() => {
if (projectLoaded && initialized) {
buildPanelsRef.current();
}
}, [projectLoaded, initialized, activeDynamicPanels, isContentBrowserDocked]);
// Effect 4: 语言变化时更新面板标题(不重建组件)| Update panel titles on locale change (don't rebuild components)
useEffect(() => {
if (projectLoaded && initialized) {
// 只更新标题,不重建组件 | Only update titles, don't rebuild components
setPanels((prev) => prev.map(panel => ({
...panel,
title: panel.id === 'scene-hierarchy' ? t('panel.sceneHierarchy') :
panel.id === 'viewport' ? t('panel.viewport') :
panel.id === 'inspector' ? t('panel.inspector') :
panel.id === 'forum' ? t('panel.forum') :
panel.id === 'content-browser' ? t('panel.contentBrowser') :
panel.title
})));
}
}, [locale, t, projectLoaded, initialized, setPanels]);
if (!initialized) {
return (
<div className="editor-loading">
<Loader2 size={32} className="animate-spin" />
<h2>Loading Editor...</h2>
</div>
);
}
if (!projectLoaded) {
const settings = SettingsService.getInstance();
const recentProjects = settings.getRecentProjects();
return (
<>
<StartupPage
onOpenProject={handleOpenProject}
onCreateProject={handleCreateProject}
onOpenRecentProject={handleOpenRecentProject}
onRemoveRecentProject={(projectPath) => {
settings.removeRecentProject(projectPath);
// 强制重新渲染 | Force re-render
setStatus(t('header.status.ready'));
}}
onDeleteProject={async (projectPath) => {
console.log('[App] onDeleteProject called with path:', projectPath);
try {
console.log('[App] Calling TauriAPI.deleteFolder...');
await TauriAPI.deleteFolder(projectPath);
console.log('[App] deleteFolder succeeded');
// 删除成功后从列表中移除并触发重新渲染
// Remove from list and trigger re-render after successful deletion
settings.removeRecentProject(projectPath);
setStatus(t('header.status.ready'));
} catch (error) {
console.error('[App] Failed to delete project:', error);
setErrorDialog({
title: t('project.deleteFailed'),
message: `${t('project.deleteFailed')}:\n${error instanceof Error ? error.message : String(error)}`
});
}
}}
onLocaleChange={handleLocaleChange}
recentProjects={recentProjects}
/>
<ProjectCreationWizard
isOpen={showProjectWizard}
onClose={() => setShowProjectWizard(false)}
onCreateProject={handleCreateProjectFromWizard}
onBrowsePath={handleBrowseProjectPath}
locale={locale}
/>
{isLoading && (
<div className="loading-overlay">
<div className="loading-content">
<Loader2 size={40} className="animate-spin" />
<p className="loading-message">{loadingMessage}</p>
</div>
</div>
)}
{errorDialog && (
<ErrorDialog
title={errorDialog.title}
message={errorDialog.message}
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);
}}
/>
)}
</>
);
}
const projectName = currentProjectPath ? currentProjectPath.split(/[\\/]/).pop() : 'Untitled';
return (
<div className="editor-container">
{!isEditorFullscreen && (
<>
<TitleBar
projectName={projectName}
uiRegistry={uiRegistry || undefined}
messageHub={messageHub || undefined}
pluginManager={pluginManager || undefined}
onNewScene={handleNewScene}
onOpenScene={handleOpenScene}
onSaveScene={handleSaveScene}
onSaveSceneAs={handleSaveSceneAs}
onOpenProject={handleOpenProject}
onCloseProject={handleCloseProject}
onExit={handleExit}
onOpenPluginManager={() => {
setSettingsInitialCategory('plugins');
setShowSettings(true);
}}
onOpenProfiler={() => setShowProfiler(true)}
onOpenPortManager={() => setShowPortManager(true)}
onOpenSettings={() => setShowSettings(true)}
onToggleDevtools={handleToggleDevtools}
onOpenAbout={handleOpenAbout}
onCreatePlugin={handleCreatePlugin}
onReloadPlugins={handleReloadPlugins}
onOpenBuildSettings={() => setShowBuildSettings(true)}
/>
<MainToolbar
messageHub={messageHub || undefined}
commandManager={commandManager}
onSaveScene={handleSaveScene}
onOpenScene={handleOpenScene}
/>
</>
)}
<CompilerConfigDialog
isOpen={compilerDialog.isOpen}
compilerId={compilerDialog.compilerId}
projectPath={currentProjectPath}
currentFileName={compilerDialog.currentFileName}
onClose={closeCompilerDialog}
onCompileComplete={(result) => {
if (result.success) {
showToast(result.message, 'success');
} else {
showToast(result.message, 'error');
}
}}
/>
<div className="editor-content">
<FlexLayoutDockContainer
ref={layoutContainerRef}
panels={panels}
activePanelId={activePanelId}
messageHub={messageHubRef.current}
onPanelClose={(panelId) => {
logger.info('Panel closed:', panelId);
// 如果关闭的是内容管理器,重置停靠状态
// If closing content browser, reset dock state
if (panelId === 'content-browser') {
setIsContentBrowserDocked(false);
}
removeDynamicPanel(panelId);
}}
/>
</div>
<StatusBar
pluginCount={pluginManager?.getAllPlugins().length ?? 0}
entityCount={entityStore?.getAllEntities().length ?? 0}
messageHub={messageHub}
logService={logService}
locale={locale}
projectPath={currentProjectPath}
onOpenScene={handleOpenSceneByPath}
onDockContentBrowser={() => setIsContentBrowserDocked(true)}
onResetLayout={() => layoutContainerRef.current?.resetLayout()}
/>
{(showProfiler || showAdvancedProfiler) && (
<AdvancedProfilerWindow onClose={() => {
setShowProfiler(false);
setShowAdvancedProfiler(false);
}} />
)}
{showPortManager && (
<PortManager onClose={() => setShowPortManager(false)} />
)}
{showSettings && settingsRegistry && (
<SettingsWindow
onClose={() => {
setShowSettings(false);
setSettingsInitialCategory(undefined);
}}
settingsRegistry={settingsRegistry}
initialCategoryId={settingsInitialCategory}
/>
)}
{showAbout && (
<AboutDialog onClose={() => setShowAbout(false)} />
)}
{showPluginGenerator && (
<PluginGeneratorWindow
onClose={() => setShowPluginGenerator(false)}
projectPath={currentProjectPath}
onSuccess={async () => {
if (currentProjectPath && pluginManager) {
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManager);
}
}}
/>
)}
{showBuildSettings && (
<BuildSettingsWindow
onClose={() => setShowBuildSettings(false)}
projectPath={currentProjectPath || undefined}
buildService={buildService || undefined}
sceneManager={sceneManager || undefined}
projectService={projectServiceState || undefined}
availableScenes={availableScenes}
/>
)}
{errorDialog && (
<ErrorDialog
title={errorDialog.title}
message={errorDialog.message}
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>
);
}
function AppWithToast() {
return (
<ToastProvider>
<App />
</ToastProvider>
);
}
export default AppWithToast;