diff --git a/packages/core/src/ECS/Core/ComponentStorage/ComponentRegistry.ts b/packages/core/src/ECS/Core/ComponentStorage/ComponentRegistry.ts index 48de0f38..261e0150 100644 --- a/packages/core/src/ECS/Core/ComponentStorage/ComponentRegistry.ts +++ b/packages/core/src/ECS/Core/ComponentStorage/ComponentRegistry.ts @@ -21,6 +21,14 @@ export class ComponentRegistry { private static maskCache = new Map(); private static nextBitIndex = 0; + /** + * 热更新模式标志,默认禁用 + * Hot reload mode flag, disabled by default + * 编辑器环境应启用此选项以支持脚本热更新 + * Editor environment should enable this to support script hot reload + */ + private static hotReloadEnabled = false; + /** * 注册组件类型并分配位掩码 * @param componentType 组件类型 @@ -34,11 +42,27 @@ export class ComponentRegistry { return existingIndex; } - // 检查是否有同名但不同类的组件已注册 - if (this.componentNameToType.has(typeName)) { + // 检查是否有同名但不同类的组件已注册(热更新场景) + // Check if a component with the same name but different class is registered (hot reload scenario) + if (this.hotReloadEnabled && this.componentNameToType.has(typeName)) { const existingType = this.componentNameToType.get(typeName); if (existingType !== componentType) { - console.warn(`[ComponentRegistry] Component name conflict: "${typeName}" already registered with different class. Existing: ${existingType?.name}, New: ${componentType.name}`); + // 热更新:替换旧的类为新的类,复用相同的 bitIndex + // Hot reload: replace old class with new class, reuse the same bitIndex + const existingIndex = this.componentTypes.get(existingType!)!; + + // 移除旧类的映射 + // Remove old class mapping + this.componentTypes.delete(existingType!); + + // 用新类更新映射 + // Update mappings with new class + this.componentTypes.set(componentType, existingIndex); + this.bitIndexToType.set(existingIndex, componentType); + this.componentNameToType.set(typeName, componentType); + + console.log(`[ComponentRegistry] Hot reload: replaced component "${typeName}"`); + return existingIndex; } } @@ -209,6 +233,32 @@ export class ComponentRegistry { this.maskCache.clear(); } + /** + * 启用热更新模式 + * Enable hot reload mode + * 在编辑器环境中调用以支持脚本热更新 + * Call in editor environment to support script hot reload + */ + public static enableHotReload(): void { + this.hotReloadEnabled = true; + } + + /** + * 禁用热更新模式 + * Disable hot reload mode + */ + public static disableHotReload(): void { + this.hotReloadEnabled = false; + } + + /** + * 检查热更新模式是否启用 + * Check if hot reload mode is enabled + */ + public static isHotReloadEnabled(): boolean { + return this.hotReloadEnabled; + } + /** * 重置注册表(用于测试) */ @@ -219,5 +269,6 @@ export class ComponentRegistry { this.componentNameToId.clear(); this.maskCache.clear(); this.nextBitIndex = 0; + this.hotReloadEnabled = false; } } diff --git a/packages/core/tests/ECS/Core/ComponentRegistry.extended.test.ts b/packages/core/tests/ECS/Core/ComponentRegistry.extended.test.ts index 8b76fe46..8ff13820 100644 --- a/packages/core/tests/ECS/Core/ComponentRegistry.extended.test.ts +++ b/packages/core/tests/ECS/Core/ComponentRegistry.extended.test.ts @@ -180,6 +180,103 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => { }); }); + describe('热更新模式', () => { + it('默认应该禁用热更新模式', () => { + expect(ComponentRegistry.isHotReloadEnabled()).toBe(false); + }); + + it('应该能够启用和禁用热更新模式', () => { + expect(ComponentRegistry.isHotReloadEnabled()).toBe(false); + + ComponentRegistry.enableHotReload(); + expect(ComponentRegistry.isHotReloadEnabled()).toBe(true); + + ComponentRegistry.disableHotReload(); + expect(ComponentRegistry.isHotReloadEnabled()).toBe(false); + }); + + it('reset 应该重置热更新模式为禁用', () => { + ComponentRegistry.enableHotReload(); + expect(ComponentRegistry.isHotReloadEnabled()).toBe(true); + + ComponentRegistry.reset(); + expect(ComponentRegistry.isHotReloadEnabled()).toBe(false); + }); + + it('启用热更新时应该替换同名组件类', () => { + ComponentRegistry.enableHotReload(); + + // 模拟热更新场景:两个不同的类但有相同的 constructor.name + // Simulate hot reload: two different classes with same constructor.name + const createHotReloadComponent = (version: number) => { + // 使用 eval 创建具有相同名称的类 + // Use function constructor to create classes with same name + const cls = (new Function('Component', ` + return class HotReloadTestComponent extends Component { + constructor() { + super(); + this.version = ${version}; + } + } + `))(Component); + return cls; + }; + + const TestComponentV1 = createHotReloadComponent(1); + const TestComponentV2 = createHotReloadComponent(2); + + // 确保两个类名相同但不是同一个类 + expect(TestComponentV1.name).toBe(TestComponentV2.name); + expect(TestComponentV1).not.toBe(TestComponentV2); + + const index1 = ComponentRegistry.register(TestComponentV1); + const index2 = ComponentRegistry.register(TestComponentV2); + + // 应该复用相同的 bitIndex + expect(index1).toBe(index2); + + // 新类应该替换旧类 + expect(ComponentRegistry.isRegistered(TestComponentV2)).toBe(true); + expect(ComponentRegistry.isRegistered(TestComponentV1)).toBe(false); + }); + + it('禁用热更新时不应该替换同名组件类', () => { + // 确保热更新被禁用 + ComponentRegistry.disableHotReload(); + + // 创建两个同名组件 + // Create two classes with same constructor.name + const createNoHotReloadComponent = (id: number) => { + const cls = (new Function('Component', ` + return class NoHotReloadComponent extends Component { + constructor() { + super(); + this.id = ${id}; + } + } + `))(Component); + return cls; + }; + + const TestCompA = createNoHotReloadComponent(1); + const TestCompB = createNoHotReloadComponent(2); + + // 确保两个类名相同但不是同一个类 + expect(TestCompA.name).toBe(TestCompB.name); + expect(TestCompA).not.toBe(TestCompB); + + const index1 = ComponentRegistry.register(TestCompA); + const index2 = ComponentRegistry.register(TestCompB); + + // 应该分配不同的 bitIndex(因为热更新被禁用) + expect(index2).toBe(index1 + 1); + + // 两个类都应该被注册 + expect(ComponentRegistry.isRegistered(TestCompA)).toBe(true); + expect(ComponentRegistry.isRegistered(TestCompB)).toBe(true); + }); + }); + describe('边界情况', () => { it('应该正确处理第 64 个组件(边界)', () => { const scene = new Scene(); diff --git a/packages/editor-app/.gitignore b/packages/editor-app/.gitignore index 07e39c29..d9c691c1 100644 --- a/packages/editor-app/.gitignore +++ b/packages/editor-app/.gitignore @@ -1,2 +1,4 @@ # Generated runtime files src-tauri/runtime/ +src-tauri/engine/ +src-tauri/bin/ diff --git a/packages/editor-app/scripts/bundle-runtime.mjs b/packages/editor-app/scripts/bundle-runtime.mjs index 604f8481..cd4c1d98 100644 --- a/packages/editor-app/scripts/bundle-runtime.mjs +++ b/packages/editor-app/scripts/bundle-runtime.mjs @@ -22,10 +22,11 @@ if (!fs.existsSync(bundleDir)) { } // Files to bundle +// 需要打包的文件 const filesToBundle = [ { - src: path.join(rootPath, 'packages/platform-web/dist/runtime.browser.js'), - dst: path.join(bundleDir, 'runtime.browser.js') + src: path.join(rootPath, 'packages/platform-web/dist/index.mjs'), + dst: path.join(bundleDir, 'platform-web.mjs') }, { src: path.join(rootPath, 'packages/engine/pkg/es_engine_bg.wasm'), @@ -96,34 +97,101 @@ for (const { src, dst } of typeFilesToBundle) { } } -// Update tauri.conf.json to include runtime directory -if (success) { - const tauriConfigPath = path.join(editorPath, 'src-tauri', 'tauri.conf.json'); - const config = JSON.parse(fs.readFileSync(tauriConfigPath, 'utf8')); +// Copy engine modules directory from dist/engine to src-tauri/engine +// 复制引擎模块目录从 dist/engine 到 src-tauri/engine +const engineSrcDir = path.join(editorPath, 'dist', 'engine'); +const engineDstDir = path.join(editorPath, 'src-tauri', 'engine'); - // Add runtime directory to resources - if (!config.bundle) { - config.bundle = {}; - } - if (!config.bundle.resources) { - config.bundle.resources = {}; +/** + * Recursively copy directory + * 递归复制目录 + */ +function copyDirRecursive(src, dst) { + if (!fs.existsSync(src)) { + return false; } - // Handle both array and object format for resources - if (Array.isArray(config.bundle.resources)) { - if (!config.bundle.resources.includes('runtime/**/*')) { - config.bundle.resources.push('runtime/**/*'); - fs.writeFileSync(tauriConfigPath, JSON.stringify(config, null, 2)); - console.log('✓ Updated tauri.conf.json with runtime resources'); - } - } else if (typeof config.bundle.resources === 'object') { - // Object format - add runtime files if not present - if (!config.bundle.resources['runtime/**/*']) { - config.bundle.resources['runtime/**/*'] = '.'; - fs.writeFileSync(tauriConfigPath, JSON.stringify(config, null, 2)); - console.log('✓ Updated tauri.conf.json with runtime resources'); + if (!fs.existsSync(dst)) { + fs.mkdirSync(dst, { recursive: true }); + } + + const entries = fs.readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const dstPath = path.join(dst, entry.name); + + if (entry.isDirectory()) { + copyDirRecursive(srcPath, dstPath); + } else { + fs.copyFileSync(srcPath, dstPath); } } + return true; +} + +if (fs.existsSync(engineSrcDir)) { + // Remove old engine directory if exists + if (fs.existsSync(engineDstDir)) { + fs.rmSync(engineDstDir, { recursive: true }); + } + + if (copyDirRecursive(engineSrcDir, engineDstDir)) { + // Count files + let fileCount = 0; + function countFiles(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + countFiles(path.join(dir, entry.name)); + } else { + fileCount++; + } + } + } + countFiles(engineDstDir); + console.log(`✓ Copied engine modules directory (${fileCount} files)`); + } +} else { + console.warn(`Engine modules directory not found: ${engineSrcDir}`); + console.log(' Build editor-app first: pnpm --filter @esengine/editor-app build'); +} + +// Copy esbuild binary for user code compilation +// 复制 esbuild 二进制文件用于用户代码编译 +const binDir = path.join(editorPath, 'src-tauri', 'bin'); +if (!fs.existsSync(binDir)) { + fs.mkdirSync(binDir, { recursive: true }); + console.log(`Created bin directory: ${binDir}`); +} + +// Platform-specific esbuild binary paths +// 平台特定的 esbuild 二进制路径 +const esbuildSources = { + win32: path.join(rootPath, 'node_modules/@esbuild/win32-x64/esbuild.exe'), + darwin: path.join(rootPath, 'node_modules/@esbuild/darwin-x64/bin/esbuild'), + linux: path.join(rootPath, 'node_modules/@esbuild/linux-x64/bin/esbuild'), +}; + +const platform = process.platform; +const esbuildSrc = esbuildSources[platform]; +const esbuildDst = path.join(binDir, platform === 'win32' ? 'esbuild.exe' : 'esbuild'); + +if (esbuildSrc && fs.existsSync(esbuildSrc)) { + try { + fs.copyFileSync(esbuildSrc, esbuildDst); + // Ensure executable permission on Unix + if (platform !== 'win32') { + fs.chmodSync(esbuildDst, 0o755); + } + const stats = fs.statSync(esbuildDst); + console.log(`✓ Bundled esbuild binary (${(stats.size / 1024 / 1024).toFixed(2)} MB)`); + } catch (error) { + console.warn(`Failed to bundle esbuild: ${error.message}`); + console.log(' User code compilation will require global esbuild installation'); + } +} else { + console.warn(`esbuild binary not found for platform ${platform}: ${esbuildSrc}`); + console.log(' User code compilation will require global esbuild installation'); } if (!success) { diff --git a/packages/editor-app/src-tauri/src/commands/compiler.rs b/packages/editor-app/src-tauri/src/commands/compiler.rs index fdacca9e..675c29e7 100644 --- a/packages/editor-app/src-tauri/src/commands/compiler.rs +++ b/packages/editor-app/src-tauri/src/commands/compiler.rs @@ -67,6 +67,34 @@ pub struct CompileResult { pub output_path: Option, } +/// Environment check result. +/// 环境检测结果。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentCheckResult { + /// Whether all required tools are available | 所有必需工具是否可用 + pub ready: bool, + /// esbuild availability status | esbuild 可用性状态 + pub esbuild: ToolStatus, +} + +/// Tool availability status. +/// 工具可用性状态。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolStatus { + /// Whether the tool is available | 工具是否可用 + pub available: bool, + /// Tool version (if available) | 工具版本(如果可用) + pub version: Option, + /// Tool path (if available) | 工具路径(如果可用) + pub path: Option, + /// Source of the tool: "bundled", "local", "global" | 工具来源 + pub source: Option, + /// Error message (if not available) | 错误信息(如果不可用) + pub error: Option, +} + /// File change event sent to frontend. /// 发送到前端的文件变更事件。 #[derive(Debug, Clone, Serialize)] @@ -78,6 +106,82 @@ pub struct FileChangeEvent { pub paths: Vec, } +/// Check development environment. +/// 检测开发环境。 +/// +/// Checks if all required tools (esbuild, etc.) are available. +/// 检查所有必需的工具是否可用。 +#[command] +pub async fn check_environment() -> Result { + let esbuild_status = check_esbuild_status(); + + Ok(EnvironmentCheckResult { + ready: esbuild_status.available, + esbuild: esbuild_status, + }) +} + +/// Check esbuild availability and get its status. +/// 检查 esbuild 可用性并获取其状态。 +fn check_esbuild_status() -> ToolStatus { + // Try bundled esbuild first | 首先尝试打包的 esbuild + if let Some(bundled_path) = find_bundled_esbuild() { + match get_esbuild_version(&bundled_path) { + Ok(version) => { + return ToolStatus { + available: true, + version: Some(version), + path: Some(bundled_path), + source: Some("bundled".to_string()), + error: None, + }; + } + Err(e) => { + println!("[Environment] Bundled esbuild found but failed to get version: {}", e); + } + } + } + + // Try global esbuild | 尝试全局 esbuild + let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" }; + match get_esbuild_version(global_esbuild) { + Ok(version) => { + ToolStatus { + available: true, + version: Some(version), + path: Some(global_esbuild.to_string()), + source: Some("global".to_string()), + error: None, + } + } + Err(_) => { + ToolStatus { + available: false, + version: None, + path: None, + source: None, + error: Some("esbuild not found | 未找到 esbuild".to_string()), + } + } + } +} + +/// Get esbuild version. +/// 获取 esbuild 版本。 +fn get_esbuild_version(esbuild_path: &str) -> Result { + let output = Command::new(esbuild_path) + .arg("--version") + .output() + .map_err(|e| format!("Failed to run esbuild: {}", e))?; + + if output.status.success() { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(version) + } else { + Err("esbuild --version failed".to_string()) + } +} + /// Compile TypeScript using esbuild. /// 使用 esbuild 编译 TypeScript。 /// @@ -254,6 +358,11 @@ pub async fn watch_scripts( println!("[UserCode] Started watching: {}", watch_path_clone.display()); + // Debounce state | 防抖状态 + let mut pending_paths: std::collections::HashSet = std::collections::HashSet::new(); + let mut last_event_time = std::time::Instant::now(); + let debounce_duration = Duration::from_millis(300); + // Event loop | 事件循环 loop { // Check for shutdown | 检查关闭信号 @@ -277,19 +386,28 @@ pub async fn watch_scripts( .collect(); if !ts_paths.is_empty() { - let change_type = match event.kind { - EventKind::Create(_) => "create", - EventKind::Modify(_) => "modify", - EventKind::Remove(_) => "remove", + // Only handle create/modify/remove events | 只处理创建/修改/删除事件 + match event.kind { + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => { + // Add to pending paths and update last event time | 添加到待处理路径并更新最后事件时间 + for path in ts_paths { + pending_paths.insert(path); + } + last_event_time = std::time::Instant::now(); + } _ => continue, }; - + } + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + // Check if we should emit pending events (debounce) | 检查是否应该发送待处理事件(防抖) + if !pending_paths.is_empty() && last_event_time.elapsed() >= debounce_duration { let file_event = FileChangeEvent { - change_type: change_type.to_string(), - paths: ts_paths, + change_type: "modify".to_string(), + paths: pending_paths.drain().collect(), }; - println!("[UserCode] File change detected: {:?}", file_event); + println!("[UserCode] File change detected (debounced): {:?}", file_event); // Emit event to frontend | 向前端发送事件 if let Err(e) = app_clone.emit("user-code:file-changed", file_event) { @@ -297,9 +415,6 @@ pub async fn watch_scripts( } } } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - // No events, continue | 无事件,继续 - } Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { println!("[UserCode] Watcher channel disconnected"); break; @@ -352,10 +467,21 @@ pub async fn stop_watch_scripts( /// Find esbuild executable path. /// 查找 esbuild 可执行文件路径。 +/// +/// Search order | 搜索顺序: +/// 1. Bundled esbuild in app resources | 应用资源中打包的 esbuild +/// 2. Local node_modules | 本地 node_modules +/// 3. Global esbuild | 全局 esbuild fn find_esbuild(project_root: &str) -> Result { let project_path = Path::new(project_root); - // Try local node_modules first | 首先尝试本地 node_modules + // Try bundled esbuild first (in app resources) | 首先尝试打包的 esbuild(在应用资源中) + if let Some(bundled) = find_bundled_esbuild() { + println!("[Compiler] Using bundled esbuild: {}", bundled); + return Ok(bundled); + } + + // Try local node_modules | 尝试本地 node_modules let local_esbuild = if cfg!(windows) { project_path.join("node_modules/.bin/esbuild.cmd") } else { @@ -363,6 +489,7 @@ fn find_esbuild(project_root: &str) -> Result { }; if local_esbuild.exists() { + println!("[Compiler] Using local esbuild: {}", local_esbuild.display()); return Ok(local_esbuild.to_string_lossy().to_string()); } @@ -375,11 +502,51 @@ fn find_esbuild(project_root: &str) -> Result { .output(); match check { - Ok(output) if output.status.success() => Ok(global_esbuild.to_string()), + Ok(output) if output.status.success() => { + println!("[Compiler] Using global esbuild"); + Ok(global_esbuild.to_string()) + }, _ => Err("esbuild not found. Please install esbuild: npm install -g esbuild | 未找到 esbuild,请安装: npm install -g esbuild".to_string()) } } +/// Find bundled esbuild in app resources. +/// 在应用资源中查找打包的 esbuild。 +fn find_bundled_esbuild() -> Option { + // Get the executable path | 获取可执行文件路径 + let exe_path = std::env::current_exe().ok()?; + let exe_dir = exe_path.parent()?; + + // In development, resources are in src-tauri directory | 开发模式下,资源在 src-tauri 目录 + // In production, resources are next to the executable | 生产模式下,资源在可执行文件旁边 + let esbuild_name = if cfg!(windows) { "esbuild.exe" } else { "esbuild" }; + + // Try production path (resources next to exe) | 尝试生产路径(资源在 exe 旁边) + let prod_path = exe_dir.join("bin").join(esbuild_name); + if prod_path.exists() { + return Some(prod_path.to_string_lossy().to_string()); + } + + // Try development path (in src-tauri/bin) | 尝试开发路径(在 src-tauri/bin 中) + // This handles running via `cargo tauri dev` + let dev_path = exe_dir + .ancestors() + .find_map(|p| { + let candidate = p.join("src-tauri").join("bin").join(esbuild_name); + if candidate.exists() { + Some(candidate) + } else { + None + } + }); + + if let Some(path) = dev_path { + return Some(path.to_string_lossy().to_string()); + } + + None +} + /// Parse esbuild error output. /// 解析 esbuild 错误输出。 fn parse_esbuild_errors(stderr: &str) -> Vec { diff --git a/packages/editor-app/src-tauri/src/main.rs b/packages/editor-app/src-tauri/src/main.rs index 62423781..46ad7ffe 100644 --- a/packages/editor-app/src-tauri/src/main.rs +++ b/packages/editor-app/src-tauri/src/main.rs @@ -93,6 +93,7 @@ fn main() { commands::compile_typescript, commands::watch_scripts, commands::stop_watch_scripts, + commands::check_environment, // Build commands | 构建命令 commands::prepare_build_directory, commands::copy_directory, diff --git a/packages/editor-app/src-tauri/tauri.conf.json b/packages/editor-app/src-tauri/tauri.conf.json index 7f34ae58..415621a3 100644 --- a/packages/editor-app/src-tauri/tauri.conf.json +++ b/packages/editor-app/src-tauri/tauri.conf.json @@ -11,9 +11,11 @@ "active": true, "targets": "all", "createUpdaterArtifacts": true, - "resources": { - "runtime/**/*": "." - }, + "resources": [ + "runtime/**/*", + "engine/**/*", + "bin/*" + ], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/packages/editor-app/src/api/tauri.ts b/packages/editor-app/src/api/tauri.ts index ad5304f0..c6717557 100644 --- a/packages/editor-app/src/api/tauri.ts +++ b/packages/editor-app/src/api/tauri.ts @@ -357,6 +357,47 @@ export class TauriAPI { static convertFileSrc(filePath: string, protocol?: string): string { return convertFileSrc(filePath, protocol); } + + /** + * 检测开发环境 + * Check development environment + * + * Checks if all required tools (esbuild, etc.) are available. + * 检查所有必需的工具是否可用。 + * + * @returns 环境检测结果 | Environment check result + */ + static async checkEnvironment(): Promise { + return await invoke('check_environment'); + } +} + +/** + * 工具可用性状态 + * Tool availability status + */ +export interface ToolStatus { + /** 工具是否可用 | Whether the tool is available */ + available: boolean; + /** 工具版本 | Tool version */ + version?: string; + /** 工具路径 | Tool path */ + path?: string; + /** 工具来源: "bundled", "local", "global" | Tool source */ + source?: string; + /** 错误信息 | Error message */ + error?: string; +} + +/** + * 环境检测结果 + * Environment check result + */ +export interface EnvironmentCheckResult { + /** 所有必需工具是否可用 | Whether all required tools are available */ + ready: boolean; + /** esbuild 可用性状态 | esbuild availability status */ + esbuild: ToolStatus; } export interface DirectoryEntry { diff --git a/packages/editor-app/src/app/managers/ServiceRegistry.ts b/packages/editor-app/src/app/managers/ServiceRegistry.ts index 32ad2f85..a5b9ae80 100644 --- a/packages/editor-app/src/app/managers/ServiceRegistry.ts +++ b/packages/editor-app/src/app/managers/ServiceRegistry.ts @@ -39,7 +39,8 @@ import { WeChatBuildPipeline, moduleRegistry, UserCodeService, - UserCodeTarget + UserCodeTarget, + type HotReloadEvent } from '@esengine/editor-core'; import { ViewportService } from '../../services/ViewportService'; import { TransformComponent } from '@esengine/engine-core'; @@ -146,6 +147,10 @@ export class ServiceRegistry { CoreComponentRegistry.register(comp.type as any); } + // Enable hot reload for editor environment + // 在编辑器环境中启用热更新 + CoreComponentRegistry.enableHotReload(); + const projectService = new ProjectService(messageHub, fileAPI); const componentDiscovery = new ComponentDiscoveryService(messageHub); const propertyMetadata = new PropertyMetadataService(); @@ -321,10 +326,46 @@ export class ServiceRegistry { messageHub.subscribe('project:opened', async (data: { path: string; type: string; name: string }) => { currentProjectPath = data.path; await compileAndLoadUserScripts(data.path); + + // Start watching for file changes (external editor support) + // 开始监视文件变更(支持外部编辑器) + userCodeService.watch(data.path, async (event) => { + console.log('[UserCodeService] Hot reload event:', event.changedFiles); + + if (event.newModule) { + // 1. Register new/updated components to registries + // 1. 注册新的/更新的组件到注册表 + userCodeService.registerComponents(event.newModule, componentRegistry); + + // 2. Hot reload: update prototype chain of existing instances + // 2. 热更新:更新现有实例的原型链 + const updatedCount = userCodeService.hotReloadInstances(event.newModule); + console.log(`[UserCodeService] Hot reloaded ${updatedCount} component instances`); + + // 3. Notify that user code has been reloaded + // 3. 通知用户代码已重新加载 + messageHub.publish('usercode:reloaded', { + projectPath: data.path, + exports: Object.keys(event.newModule.exports), + updatedInstances: updatedCount + }); + } + }).catch(err => { + console.warn('[UserCodeService] Failed to start file watcher:', err); + }); }); - // Subscribe to script file changes (create/delete/modify) - // 订阅脚本文件变更(创建/删除/修改) + // Subscribe to project:closed to stop watching + // 订阅 project:closed 以停止监视 + messageHub.subscribe('project:closed', async () => { + currentProjectPath = null; + await userCodeService.stopWatch(); + }); + + // Subscribe to script file changes (create/delete) from editor operations + // 订阅编辑器操作的脚本文件变更(创建/删除) + // Note: file:modified is handled by the Rust file watcher for external editor support + // 注意:file:modified 由 Rust 文件监视器处理以支持外部编辑器 messageHub.subscribe('file:created', async (data: { path: string }) => { if (currentProjectPath && this.isScriptFile(data.path)) { await compileAndLoadUserScripts(currentProjectPath); @@ -337,12 +378,6 @@ export class ServiceRegistry { } }); - messageHub.subscribe('file:modified', async (data: { path: string }) => { - if (currentProjectPath && this.isScriptFile(data.path)) { - await compileAndLoadUserScripts(currentProjectPath); - } - }); - // 注册默认场景模板 - 创建默认相机 // Register default scene template - creates default camera this.registerDefaultSceneTemplate(); diff --git a/packages/editor-app/src/components/StartupPage.tsx b/packages/editor-app/src/components/StartupPage.tsx index 39c3a524..686c939f 100644 --- a/packages/editor-app/src/components/StartupPage.tsx +++ b/packages/editor-app/src/components/StartupPage.tsx @@ -1,8 +1,9 @@ import { useState, useEffect, useRef } from 'react'; import { getVersion } from '@tauri-apps/api/app'; -import { Globe, ChevronDown, Download, X, Loader2, Trash2 } from 'lucide-react'; +import { Globe, ChevronDown, Download, X, Loader2, Trash2, CheckCircle, AlertCircle } from 'lucide-react'; import { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater'; import { StartupLogo } from './StartupLogo'; +import { TauriAPI, type EnvironmentCheckResult } from '../api/tauri'; import '../styles/StartupPage.css'; type Locale = 'en' | 'zh'; @@ -33,6 +34,8 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec const [updateInfo, setUpdateInfo] = useState(null); const [showUpdateBanner, setShowUpdateBanner] = useState(false); const [isInstalling, setIsInstalling] = useState(false); + const [envCheck, setEnvCheck] = useState(null); + const [showEnvStatus, setShowEnvStatus] = useState(false); const langMenuRef = useRef(null); useEffect(() => { @@ -59,6 +62,24 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec }); }, []); + // 启动时检测开发环境 + useEffect(() => { + TauriAPI.checkEnvironment().then((result) => { + setEnvCheck(result); + // 如果环境就绪,在控制台显示信息 + if (result.ready) { + console.log('[Environment] Ready ✓'); + console.log(`[Environment] esbuild: ${result.esbuild.version} (${result.esbuild.source})`); + } else { + // 环境有问题,显示提示 + setShowEnvStatus(true); + console.warn('[Environment] Not ready:', result.esbuild.error); + } + }).catch((error) => { + console.error('[Environment] Check failed:', error); + }); + }, []); + const translations = { en: { title: 'ESEngine Editor', @@ -76,7 +97,11 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec deleteConfirmTitle: 'Delete Project', deleteConfirmMessage: 'Are you sure you want to permanently delete this project? This action cannot be undone.', cancel: 'Cancel', - delete: 'Delete' + delete: 'Delete', + envReady: 'Environment Ready', + envNotReady: 'Environment Issue', + esbuildReady: 'esbuild ready', + esbuildMissing: 'esbuild not found' }, zh: { title: 'ESEngine 编辑器', @@ -94,7 +119,11 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec deleteConfirmTitle: '删除项目', deleteConfirmMessage: '确定要永久删除此项目吗?此操作无法撤销。', cancel: '取消', - delete: '删除' + delete: '删除', + envReady: '环境就绪', + envNotReady: '环境问题', + esbuildReady: 'esbuild 就绪', + esbuildMissing: '未找到 esbuild' } }; @@ -220,6 +249,43 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
{versionText} + + {/* 环境状态指示器 | Environment Status Indicator */} + {envCheck && ( +
setShowEnvStatus(!showEnvStatus)} + title={envCheck.ready ? t.envReady : t.envNotReady} + > + {envCheck.ready ? ( + + ) : ( + + )} + {showEnvStatus && ( +
+
+ {envCheck.ready ? t.envReady : t.envNotReady} +
+
+ {envCheck.esbuild.available ? ( + <> + + esbuild {envCheck.esbuild.version} + ({envCheck.esbuild.source}) + + ) : ( + <> + + {t.esbuildMissing} + + )} +
+
+ )} +
+ )} + {onLocaleChange && (